├── bench ├── data │ └── .gitkeep ├── .gitignore ├── logger.go ├── Makefile ├── README.md ├── Dockerfile ├── go.mod ├── option.go ├── scenario_ban.go ├── models.go ├── scenario_fav.go ├── scenario_admin.go ├── cmd │ ├── httpd │ │ └── main.go │ └── bench │ │ └── main.go ├── scenario_anon.go ├── action.go ├── rand.go ├── validation.go ├── go.sum ├── scenario.go ├── scenario_normal.go └── scenario_valiadtion.go ├── webapp ├── golang │ ├── .gitkeep │ ├── public │ ├── views │ │ ├── footer.html │ │ ├── mypage.html │ │ ├── top.html │ │ ├── playlist.html │ │ ├── login.html │ │ ├── header.html │ │ ├── signup.html │ │ └── playlist_edit.html │ ├── Dockerfile │ ├── go.mod │ ├── db.go │ ├── api.go │ └── go.sum ├── mysql │ ├── logs │ │ └── .gitkeep │ └── my.cnf ├── node │ ├── public │ ├── .gitignore │ ├── .dockerignore │ ├── nodemon.json │ ├── Dockerfile │ ├── src │ │ ├── views │ │ │ ├── mypage.ejs │ │ │ ├── top.ejs │ │ │ ├── playlist.ejs │ │ │ ├── login.ejs │ │ │ ├── signup.ejs │ │ │ ├── layout.ejs │ │ │ └── playlist_edit.ejs │ │ └── types │ │ │ ├── db.ts │ │ │ └── api.ts │ └── package.json ├── public │ └── assets │ │ ├── .gitignore │ │ ├── js │ │ ├── logout.js │ │ ├── login.js │ │ ├── signup.js │ │ ├── playlist.js │ │ ├── mypage.js │ │ ├── top.js │ │ ├── playlist_edit.js │ │ └── lib.js │ │ └── css │ │ └── listen80.css ├── nginx │ └── conf.d │ │ └── default.conf └── docker-compose.yml ├── .node-version ├── packer ├── .gitignore ├── Makefile ├── provisioning.sh └── isucon.pkr.hcl ├── .gitignore ├── docs ├── listen80.png ├── screen.md ├── scenario.md ├── db_schema.md ├── README.md └── API.md ├── sql ├── 00_database_user.sql └── 50_listen80_schema.sql ├── data ├── Makefile └── build_fake_data.py ├── Makefile ├── LICENSE └── README.md /bench/data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/golang/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16.14.2 2 | -------------------------------------------------------------------------------- /webapp/mysql/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/node/public: -------------------------------------------------------------------------------- 1 | ../public -------------------------------------------------------------------------------- /webapp/golang/public: -------------------------------------------------------------------------------- 1 | ../public -------------------------------------------------------------------------------- /packer/.gitignore: -------------------------------------------------------------------------------- 1 | *.tar.gz 2 | isucon/* 3 | -------------------------------------------------------------------------------- /webapp/node/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /webapp/public/assets/.gitignore: -------------------------------------------------------------------------------- 1 | songs.json 2 | -------------------------------------------------------------------------------- /bench/.gitignore: -------------------------------------------------------------------------------- 1 | ./bench 2 | ./httpd 3 | data/* 4 | -------------------------------------------------------------------------------- /webapp/node/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .envrc 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vetur.config.js 2 | .envrc 3 | isucon_listen80_dump* 4 | users.json 5 | -------------------------------------------------------------------------------- /docs/listen80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayac/kayac-isucon-2022/HEAD/docs/listen80.png -------------------------------------------------------------------------------- /webapp/mysql/my.cnf: -------------------------------------------------------------------------------- 1 | [mysqld] 2 | character-set-server=utf8mb4 3 | collation-server=utf8mb4_unicode_ci 4 | -------------------------------------------------------------------------------- /webapp/golang/views/footer.html: -------------------------------------------------------------------------------- 1 | {{ define "footer" }} 2 | 3 | 4 | 5 | 6 | 7 | {{ end }} 8 | -------------------------------------------------------------------------------- /webapp/node/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "exec": "ts-node ./src/main.ts" 5 | } 6 | -------------------------------------------------------------------------------- /webapp/nginx/conf.d/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | root /public/; 5 | 6 | location / { 7 | proxy_set_header Host $host; 8 | proxy_pass http://app:3000; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /sql/00_database_user.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE IF NOT EXISTS `isucon_listen80`; 2 | CREATE USER isucon IDENTIFIED BY 'isucon'; 3 | GRANT ALL PRIVILEGES ON isucon_listen80.* TO 'isucon'@'%'; 4 | 5 | SET PERSIST local_infile=1; 6 | -------------------------------------------------------------------------------- /webapp/golang/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18.1-bullseye 2 | RUN apt-get update && apt-get -y upgrade 3 | RUN mkdir -p /home/isucon 4 | COPY . /home/isucon/webapp 5 | WORKDIR /home/isucon/webapp 6 | RUN go build -o isucon ./... 7 | CMD ["./isucon"] 8 | -------------------------------------------------------------------------------- /webapp/node/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.14.2-bullseye 2 | RUN apt-get update && apt-get -y upgrade 3 | RUN mkdir -p /home/isucon 4 | COPY . /home/isucon/webapp 5 | WORKDIR /home/isucon/webapp 6 | RUN npm install -g npm@8.6.0 && npm install 7 | CMD ["npx", "nodemon"] 8 | -------------------------------------------------------------------------------- /bench/logger.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | var ( 9 | // 選手向け情報を出力するロガー 10 | ContestantLogger = log.New(os.Stdout, "", log.Ltime|log.Lmicroseconds) 11 | // 大会運営向け情報を出力するロガー 12 | AdminLogger = log.New(os.Stderr, "[ADMIN] ", log.Ltime|log.Lmicroseconds) 13 | ) 14 | -------------------------------------------------------------------------------- /data/Makefile: -------------------------------------------------------------------------------- 1 | FILES=isucon_listen80_dump.sql songs.json users.json 2 | 3 | dump: 4 | mysql -uisucon -pisucon isucon_listen80 < ../sql/50_listen80_schema.sql 5 | python3 build_fake_data.py 30000 6 | mysqldump -uisucon -pisucon -B isucon_listen80 --no-tablespaces > isucon_listen80_dump.sql 7 | tar czvf isucon_listen80_dump.tar.gz $(FILES) 8 | 9 | clean: 10 | rm -f isucon_listen80_dump.* $(FILES) 11 | -------------------------------------------------------------------------------- /bench/Makefile: -------------------------------------------------------------------------------- 1 | export STAG := $(shell echo $(TAG) | cut -d/ -f2) 2 | export HASH := $(shell git rev-parse HEAD) 3 | 4 | bench: *.go go.* cmd/bench/*.go 5 | go build -o bench cmd/bench/main.go 6 | 7 | httpd: *.go go.* cmd/httpd/*.go 8 | go build -o httpd cmd/httpd/main.go 9 | 10 | run: bench 11 | ./bench 12 | 13 | prepare: bench 14 | ./bench -prepare-only 15 | 16 | 5s: bench 17 | ./bench -duration 5s 18 | 19 | 20 | .PHONY: clean 21 | clean: 22 | rm -f bench httpd 23 | -------------------------------------------------------------------------------- /packer/Makefile: -------------------------------------------------------------------------------- 1 | isucon: 2 | git clone git@github.com:kayac/kayac-isucon-2022.git isucon 3 | 4 | isucon.tar.gz: clean isucon 5 | cd isucon && make dataset 6 | cd isucon/bench && \ 7 | GOOS=linux GOARCH=amd64 make bench && mv bench bench.amd64 && \ 8 | GOOS=linux GOARCH=arm64 make bench && mv bench bench.arm64 9 | rm -fr isucon/.git *.tar.gz 10 | tar cvzf isucon.tar.gz isucon/ 11 | 12 | clean: 13 | rm -fr isucon isucon.tar.gz 14 | 15 | ami: isucon.tar.gz 16 | packer build . 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dataset 2 | 3 | dataset: isucon_listen80_dump.tar.gz 4 | tar xvf isucon_listen80_dump.tar.gz 5 | cp songs.json users.json bench/data/ 6 | cp songs.json webapp/public/assets/ 7 | mv isucon_listen80_dump.sql sql/90_isucon_listen80_dump.sql 8 | rm -f songs.json users.json 9 | 10 | isucon_listen80_dump.tar.gz: 11 | curl -sLO https://github.com/kayac/kayac-isucon-2022/releases/download/v0.0.1/isucon_listen80_dump.tar.gz 12 | 13 | clean: 14 | rm -f isucon_listen80_dump.tar.gz bench/data/* webapp/public/assets/songs.json 15 | -------------------------------------------------------------------------------- /bench/README.md: -------------------------------------------------------------------------------- 1 | # bench 2 | 3 | ## How to run 4 | 5 | 前提 6 | 7 | - repo root にいる状態 8 | - docker-compose で起動している状態 9 | - nginx が port 80 10 | - mysql が port 13306 11 | 12 | ```console 13 | $ gh release download dummydata/20220421_0056-prod # もしくはreleaseからisucon_listen80_dump_prod.tar.gzをダウンロード 14 | $ tar xvf isucon_listen80_dump_prod.tar.gz 15 | $ mysql -uroot -proot --host 127.0.0.1 --port 13306 < isucon_listen80_dump.sql 16 | $ cd bench 17 | $ make 18 | $ ./bench -target-url http://localhost # nginxのportを変えている場合はportを合わせる 19 | ``` 20 | -------------------------------------------------------------------------------- /bench/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18.1-bullseye AS builder 2 | 3 | COPY . /tmp/bench/ 4 | WORKDIR /tmp/bench/ 5 | RUN make bench httpd 6 | 7 | FROM debian:bullseye-slim 8 | 9 | RUN apt-get update && apt-get -y upgrade && apt-get -y install ca-certificates curl 10 | RUN mkdir -p /home/isucon/bench 11 | COPY --from=builder /tmp/bench/httpd /usr/local/bin/httpd 12 | COPY --from=builder /tmp/bench/bench /home/isucon/bench/bench 13 | COPY --from=builder /tmp/bench/data/ /home/isucon/bench/data/ 14 | WORKDIR /home/isucon/bench 15 | 16 | ENTRYPOINT ["/usr/local/bin/httpd"] 17 | -------------------------------------------------------------------------------- /webapp/node/src/views/mypage.ejs: -------------------------------------------------------------------------------- 1 |
2 |

マイページ

3 |

<%= displayName %> さんこんにちは

4 | 5 |

自分のプレイリスト

6 |
7 | 8 |
9 |
10 | 11 |

ラブなプレイリスト

12 |
13 | 14 | 15 | 16 |
-------------------------------------------------------------------------------- /webapp/node/src/views/top.ejs: -------------------------------------------------------------------------------- 1 |
2 | <% if (loggedIn) { %> 3 |

<%= displayName %> さんこんにちは。いつもご利用ありがとうございます。

4 | <% } else { %> 5 |

Listen80のご利用ははじめてですか? いますぐサインアップしましょう!

6 | <% } %> 7 | 8 |

人気のプレイリスト

9 | 10 | 11 | 12 |

最近追加されたプレイリスト

13 | 14 |
15 | 16 | 17 | 18 |
-------------------------------------------------------------------------------- /webapp/public/assets/js/logout.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', () => { 2 | const form = document.getElementById('logout-form') 3 | 4 | const logout = async () => { 5 | const response = await fetch('/api/logout', { 6 | method: 'POST', 7 | }) 8 | 9 | return response 10 | } 11 | 12 | form.addEventListener('submit', async (event) => { 13 | event.stopPropagation() 14 | event.preventDefault() 15 | 16 | const res = await logout() 17 | if (res.status !== 200) { 18 | // something error 19 | return 20 | } 21 | 22 | window.location.href = '/' 23 | }) 24 | 25 | }) 26 | -------------------------------------------------------------------------------- /webapp/golang/views/mypage.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |

マイページ

4 |

{{ .DisplayName }} さんこんにちは

5 | 6 |

自分のプレイリスト

7 |
8 | 9 |
10 |
11 | 12 |

ラブなプレイリスト

13 |
14 | 15 | 16 | 17 |
18 | {{ template "footer" . }} 19 | -------------------------------------------------------------------------------- /webapp/golang/views/top.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 | {{ if .LoggedIn }} 4 |

{{ .DisplayName }} さんこんにちは。いつもご利用ありがとうございます。

5 | {{ else }} 6 |

Listen80のご利用ははじめてですか? いますぐサインアップしましょう!

7 | {{ end }} 8 | 9 |

人気のプレイリスト

10 | 11 | 12 | 13 |

最近追加されたプレイリスト

14 | 15 |
16 | 17 | 18 | 19 |
20 | {{ template "footer" . }} 21 | -------------------------------------------------------------------------------- /webapp/node/src/views/playlist.ejs: -------------------------------------------------------------------------------- 1 |
2 |

プレイリストの詳細

3 | 4 | 5 |
6 | 7 |
8 |

曲のリスト

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
曲名アーティスト名収録アルバムトラック番号
25 | 26 | 27 | 28 |
-------------------------------------------------------------------------------- /webapp/node/src/views/login.ejs: -------------------------------------------------------------------------------- 1 |
2 |

ログイン

3 | 4 |
5 | 6 |
7 | 8 |
9 |
10 | 11 | 12 |
13 |
14 | 15 | 16 |
17 | 18 |
19 |

アカウントがない? ほな新規登録やな

20 | 21 | 22 |
23 | -------------------------------------------------------------------------------- /webapp/golang/views/playlist.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |

プレイリストの詳細

4 | 5 | 6 |
7 | 8 |
9 |

曲のリスト

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
曲名アーティスト名収録アルバムトラック番号
26 | 27 | 28 | 29 |
30 | {{ template "footer" . }} 31 | -------------------------------------------------------------------------------- /webapp/golang/views/login.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |

ログイン

4 | 5 |
6 | 7 |
8 | 9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 | 19 |
20 |

アカウントがない? ほな新規登録やな

21 | 22 | 23 |
24 | {{ template "footer" . }} 25 | -------------------------------------------------------------------------------- /webapp/golang/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kayac/inhouse-isucon-2022/webapp/golang 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/go-sql-driver/mysql v1.6.0 7 | github.com/gorilla/sessions v1.2.1 8 | github.com/jmoiron/sqlx v1.3.5 9 | github.com/labstack/echo/v4 v4.11.2 10 | github.com/labstack/gommon v0.4.0 11 | github.com/oklog/ulid/v2 v2.0.2 12 | github.com/srinathgs/mysqlstore v0.0.0-20200417050510-9cbb9420fc4c 13 | golang.org/x/crypto v0.14.0 14 | ) 15 | 16 | require ( 17 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 18 | github.com/gorilla/securecookie v1.1.1 // indirect 19 | github.com/mattn/go-colorable v0.1.13 // indirect 20 | github.com/mattn/go-isatty v0.0.19 // indirect 21 | github.com/valyala/bytebufferpool v1.0.0 // indirect 22 | github.com/valyala/fasttemplate v1.2.2 // indirect 23 | golang.org/x/net v0.17.0 // indirect 24 | golang.org/x/sys v0.13.0 // indirect 25 | golang.org/x/text v0.13.0 // indirect 26 | golang.org/x/time v0.3.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /bench/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kayac/kayac-isucon-2022/bench 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/Songmu/timeout v0.4.0 7 | github.com/aws/aws-sdk-go v1.43.45 8 | github.com/deckarep/golang-set v1.8.0 9 | github.com/fujiwara/ridge v0.6.1 10 | github.com/isucon/isucandar v0.0.0-20220322062028-6dd56dc57d72 11 | github.com/natureglobal/realip v0.0.2 12 | github.com/oklog/ulid/v2 v2.0.2 13 | github.com/samber/lo v1.12.0 14 | golang.org/x/net v0.17.0 15 | ) 16 | 17 | require ( 18 | github.com/Songmu/wrapcommander v0.1.0 // indirect 19 | github.com/aws/aws-lambda-go v1.26.0 // indirect 20 | github.com/dsnet/compress v0.0.1 // indirect 21 | github.com/jmespath/go-jmespath v0.4.0 // indirect 22 | github.com/pires/go-proxyproto v0.6.1 // indirect 23 | github.com/pkg/errors v0.9.1 // indirect 24 | github.com/pquerna/cachecontrol v0.1.0 // indirect 25 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect 26 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 KAYAC Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /webapp/public/assets/js/login.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', () => { 2 | const form = document.getElementById('login-form') 3 | 4 | const login = async () => { 5 | const user_account = form['user_account'].value 6 | const password = form['password'].value 7 | 8 | const response = await fetch('/api/login', { 9 | method: 'POST', 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | }, 13 | body: JSON.stringify({ 14 | user_account, 15 | password, 16 | }) 17 | }) 18 | 19 | return response 20 | } 21 | 22 | form.addEventListener('submit', async (event) => { 23 | event.stopPropagation() 24 | event.preventDefault() 25 | 26 | const res = await login() 27 | if (res.status !== 200) { 28 | // something error 29 | if (res.status === 401) { 30 | window.alert('ログイン大失敗 (アカウント情報が違います)') 31 | } else { 32 | window.alert('ログイン大失敗 (サーバの調子が悪そう)') 33 | } 34 | return 35 | } 36 | 37 | window.location.href = '/mypage' 38 | }) 39 | 40 | }) 41 | 42 | -------------------------------------------------------------------------------- /webapp/node/src/views/signup.ejs: -------------------------------------------------------------------------------- 1 |
2 |

新規登録

3 | 4 |
5 | 6 |
7 | 8 |
9 |
10 | 11 | 12 |
13 |
14 | 15 | 16 |
17 |
18 | 19 | 20 |
21 |
22 | 23 | 24 |
25 | 26 |
27 |

既にアカウントがある? ほなログインやな

28 | 29 | 30 | 31 |
32 | -------------------------------------------------------------------------------- /webapp/node/src/types/db.ts: -------------------------------------------------------------------------------- 1 | import { RowDataPacket } from 'mysql2/promise' 2 | 3 | export type UserRow = RowDataPacket & { 4 | account: string 5 | password_hash: string 6 | display_name: string 7 | is_ban: boolean 8 | created_at: Date 9 | last_logined_at: Date 10 | } 11 | 12 | export type SongRow = RowDataPacket & { 13 | id: number 14 | ulid: string 15 | title: string 16 | artist_id: number 17 | album: string 18 | track_number: number 19 | is_public: boolean 20 | } 21 | 22 | export type ArtistRow = RowDataPacket & { 23 | id: number 24 | ulid: string 25 | name: string 26 | } 27 | 28 | export type PlaylistRow = RowDataPacket & { 29 | id: number 30 | ulid: string 31 | name: string 32 | user_account: string 33 | is_public: boolean 34 | created_at: Date 35 | updated_at: Date 36 | } 37 | 38 | export type PlaylistSongRow = RowDataPacket& { 39 | playlist_id: number 40 | sort_order: number 41 | song_id: number 42 | } 43 | 44 | export type PlaylistFavoriteRow = RowDataPacket & { 45 | id: number 46 | playlist_id: number 47 | favorite_user_account: string 48 | created_at: Date 49 | } 50 | -------------------------------------------------------------------------------- /webapp/golang/views/header.html: -------------------------------------------------------------------------------- 1 | {{ define "header" }} 2 | 3 | 4 | 5 | listen80👂 - new wave from music port 6 | 7 | 8 | 9 | 10 | 11 | 12 | 33 | 34 |
35 | {{ end }} 36 | -------------------------------------------------------------------------------- /webapp/golang/views/signup.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |

新規登録

4 | 5 |
6 | 7 |
8 | 9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 |
26 | 27 |
28 |

既にアカウントがある? ほなログインやな

29 | 30 | 31 | 32 |
33 | {{ template "footer" . }} 34 | -------------------------------------------------------------------------------- /webapp/public/assets/js/signup.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', () => { 2 | const form = document.getElementById('signup-form') 3 | 4 | const signup = async () => { 5 | const user_account = form['user_account'].value 6 | const password = form['password'].value 7 | const password2 = form['password2'].value 8 | const display_name = form['display_name'].value 9 | 10 | if (password !== password2) { 11 | window.alert('パスワードが一致していません!') 12 | return 13 | } 14 | 15 | const response = await fetch('/api/signup', { 16 | method: 'POST', 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | }, 20 | body: JSON.stringify({ 21 | user_account, 22 | password, 23 | display_name, 24 | }) 25 | }) 26 | 27 | return response 28 | } 29 | 30 | form.addEventListener('submit', async (event) => { 31 | event.stopPropagation() 32 | event.preventDefault() 33 | 34 | const res = await signup() 35 | if (res.status !== 200) { 36 | // something error 37 | return 38 | } 39 | 40 | window.location.href = '/mypage' 41 | }) 42 | 43 | }) 44 | 45 | -------------------------------------------------------------------------------- /webapp/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "listen80-server", 3 | "version": "1.0.0", 4 | "description": "this is server", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "server": "npx ts-node src/main.ts", 9 | "dev-server": "npx nodemon" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@types/bcrypt": "^5.0.0", 15 | "@types/express-mysql-session": "^2.1.3", 16 | "@types/express-session": "^1.17.4", 17 | "axios": "^0.26.1", 18 | "bcrypt": "^5.0.1", 19 | "cookie-session": "^2.0.0", 20 | "ejs": "^3.1.7", 21 | "express": "^4.17.3", 22 | "express-mysql-session": "^2.1.7", 23 | "express-partials": "^0.3.0", 24 | "express-session": "^1.17.2", 25 | "mysql2": "^2.3.3", 26 | "ts-node": "^10.7.0", 27 | "typescript": "^4.6.3", 28 | "ulid": "^2.3.0" 29 | }, 30 | "devDependencies": { 31 | "@types/cookie-session": "^2.0.44", 32 | "@types/ejs": "^3.1.0", 33 | "@types/express": "^4.17.13", 34 | "@types/express-partials": "^0.0.32", 35 | "@types/node": "^17.0.23", 36 | "nodemon": "^2.0.15" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /webapp/node/src/views/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | listen80👂 - new wave from music port 5 | 6 | 7 | 8 | 9 | 10 | 11 | 32 | 33 |
34 | <%- body %> 35 |
36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /bench/option.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/isucon/isucandar/agent" 7 | ) 8 | 9 | type Option struct { 10 | TargetURL string 11 | RequestTimeout time.Duration 12 | InitializeRequestTimeout time.Duration 13 | ExitErrorOnFail bool 14 | Duration time.Duration 15 | PrepareOnly bool 16 | SkipPrepare bool 17 | DataDir string 18 | Debug bool 19 | } 20 | 21 | func (o Option) String() string { 22 | return "TargetURL: " + o.TargetURL + ", RequestTimeout: " + o.RequestTimeout.String() + ", InitializeRequestTimeout: " + o.InitializeRequestTimeout.String() 23 | } 24 | 25 | func (o Option) NewAgent(forInitialize bool) (*agent.Agent, error) { 26 | agentOptions := []agent.AgentOption{ 27 | agent.WithBaseURL(o.TargetURL), 28 | agent.WithCloneTransport(agent.DefaultTransport), 29 | } 30 | 31 | // initialize 用の agent.Agent はタイムアウト時間が違うのでオプションを調整 32 | if forInitialize { 33 | agentOptions = append(agentOptions, agent.WithTimeout(o.InitializeRequestTimeout)) 34 | } else { 35 | agentOptions = append(agentOptions, agent.WithTimeout(o.RequestTimeout)) 36 | } 37 | 38 | // オプションに従って agent.Agent を生成 39 | return agent.NewAgent(agentOptions...) 40 | } 41 | -------------------------------------------------------------------------------- /webapp/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | 3 | services: 4 | nginx: 5 | image: nginx:1.20 6 | volumes: 7 | - ./nginx/conf.d:/etc/nginx/conf.d 8 | - ./public:/public 9 | ports: 10 | - "80:80" 11 | links: 12 | - app 13 | restart: always 14 | 15 | app: 16 | cpus: 1 17 | mem_limit: 1g 18 | # Go実装の場合は golang/ node実装の場合は node/ 19 | build: node/ 20 | environment: 21 | ISUCON_DB_HOST: mysql 22 | ISUCON_DB_PORT: 3306 23 | ISUCON_DB_USER: isucon 24 | ISUCON_DB_PASSWORD: isucon 25 | ISUCON_DB_NAME: isucon_listen80 26 | links: 27 | - mysql 28 | volumes: 29 | - ./public:/home/isucon/webapp/public 30 | - gopkg:/usr/local/go/pkg 31 | init: true 32 | restart: always 33 | 34 | mysql: 35 | cpus: 1 36 | mem_limit: 1g 37 | image: mysql/mysql-server:8.0.28 38 | # M1 mac(ARM)の場合は aarch64 のimageに変更する 39 | # image: mysql/mysql-server:8.0.28-aarch64 40 | environment: 41 | - "MYSQL_ROOT_HOST=%" 42 | - "MYSQL_ROOT_PASSWORD=root" 43 | volumes: 44 | - ../sql:/docker-entrypoint-initdb.d 45 | - mysql:/var/lib/mysql 46 | - ./mysql/my.cnf:/etc/my.cnf 47 | - ./mysql/logs:/var/log/mysql 48 | ports: 49 | - 13306:3306 50 | restart: always 51 | 52 | volumes: 53 | mysql: 54 | gopkg: 55 | -------------------------------------------------------------------------------- /bench/scenario_ban.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/isucon/isucandar" 8 | "github.com/isucon/isucandar/worker" 9 | ) 10 | 11 | func (s *Scenario) BannedWorker(step *isucandar.BenchmarkStep, p int32) (*worker.Worker, error) { 12 | w, err := worker.NewWorker(func(ctx context.Context, _ int) { 13 | timer := time.NewTimer(3 * time.Second) 14 | select { 15 | case <-ctx.Done(): 16 | case <-timer.C: 17 | } 18 | s.BannedScenario(ctx, step) 19 | }, 20 | // 無限回繰り返す 21 | worker.WithInfinityLoop(), 22 | worker.WithMaxParallelism(10), 23 | ) 24 | if err != nil { 25 | return nil, err 26 | } 27 | w.SetParallelism(p) 28 | return w, nil 29 | } 30 | 31 | // Ban済みUserのシナリオ 32 | func (s *Scenario) BannedScenario(ctx context.Context, step *isucandar.BenchmarkStep) error { 33 | report := timeReporter("banned") 34 | defer report() 35 | 36 | user, release := s.ChoiceUser(ctx, s.BannedUsers) 37 | defer release() 38 | if user == nil { 39 | return nil 40 | } 41 | ag, _ := user.GetAgent(s.Option) 42 | { 43 | // ログイン 44 | res, err := LoginAction(ctx, user, ag) 45 | v := ValidateResponse("ログイン", 46 | step, res, err, 47 | WithStatusCode(401), 48 | WithErrorResponse[ResponseAPIBase](), 49 | ) 50 | if v.IsEmpty() { 51 | step.AddScore(ScoreLogin) 52 | } else { 53 | return v 54 | } 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /docs/screen.md: -------------------------------------------------------------------------------- 1 | ## どの画面でどのAPIを叩いているか表! 2 | 3 | 4 | API | top | login | signup | mypage | playlist | playlist_edit | 備考 5 | ----------------------------------|-----|-------|--------|--------|----------|---------------|------ 6 | POST /api/signup | | | v | | | | 7 | POST /api/login | | v | | | | | 8 | POST /api/logout | x | x | x | x | x | x | 共通ヘッダでログイン状態の時 9 | GET /api/recent_playlists | v | | | | | | 10 | GET /api/popular_playlists | v | | | | | | 11 | GET /api/playlists | | | | v | | | 12 | POST /api/playlist/add | | | | v | | | 13 | GET /api/playlist/:ulid | | | | | v | v | 14 | POST /api/playlist/:ulid/update | | | | | | v | 15 | POST /api/playlist/:ulid/delete | | | | v | | | 16 | POST /api/playlist/:ulid/favorite | v | | | v | v | | 17 | POST /api/admin/user/ban | | | | | | | 画面なし 18 | -------------------------------------------------------------------------------- /webapp/node/src/views/playlist_edit.ejs: -------------------------------------------------------------------------------- 1 |
2 |

プレイリストの編集

3 | 4 | 5 |
6 |
7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 |
15 |
16 | 17 |

曲のリスト

18 |

曲リストの編集は、保存ボタンを押すまで反映されません。 19 |

20 | 21 |
22 | 曲の追加 23 |

先頭に追加されます。

24 | 26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
曲名アーティスト名収録アルバムトラック番号
45 | 46 | 47 | 48 | 49 |
50 | -------------------------------------------------------------------------------- /webapp/golang/views/playlist_edit.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 |
3 |

プレイリストの編集

4 | 5 | 6 |
7 |
8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 |
16 |
17 | 18 |

曲のリスト

19 |

曲リストの編集は、保存ボタンを押すまで反映されません。 20 |

21 | 22 |
23 | 曲の追加 24 |

先頭に追加されます。

25 | 27 | 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
曲名アーティスト名収録アルバムトラック番号
46 | 47 | 48 | 49 | 50 |
51 | {{ template "footer" . }} 52 | -------------------------------------------------------------------------------- /webapp/golang/db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | type UserRow struct { 6 | Account string `db:"account"` 7 | PasswordHash string `db:"password_hash"` 8 | DisplayName string `db:"display_name"` 9 | IsBan bool `db:"is_ban"` 10 | CreatedAt time.Time `db:"created_at"` 11 | LastLoginedAt time.Time `db:"last_logined_at"` 12 | } 13 | 14 | type SongRow struct { 15 | ID int `db:"id"` 16 | ULID string `db:"ulid"` 17 | Title string `db:"title"` 18 | ArtistID int `db:"artist_id"` 19 | Album string `db:"album"` 20 | TrackNumber int `db:"track_number"` 21 | IsPublic bool `db:"is_public"` 22 | } 23 | 24 | type ArtistRow struct { 25 | ID int `db:"id"` 26 | ULID string `db:"ulid"` 27 | Name string `db:"name"` 28 | } 29 | 30 | type PlaylistRow struct { 31 | ID int `db:"id"` 32 | ULID string `db:"ulid"` 33 | Name string `db:"name"` 34 | UserAccount string `db:"user_account"` 35 | IsPublic bool `db:"is_public"` 36 | CreatedAt time.Time `db:"created_at"` 37 | UpdatedAt time.Time `db:"updated_at"` 38 | } 39 | 40 | type PlaylistSongRow struct { 41 | PlaylistID int `db:"playlist_id"` 42 | SortOrder int `db:"sort_order"` 43 | SongID int `db:"song_id"` 44 | } 45 | 46 | type PlaylistFavoriteRow struct { 47 | ID int `db:"id"` 48 | PlaylistID int `db:"playlist_id"` 49 | FavoriteUserAccount string `db:"favorite_user_account"` 50 | CreatedAt time.Time `db:"created_at"` 51 | } 52 | -------------------------------------------------------------------------------- /packer/provisioning.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -exu 4 | arch=$(uname -m) 5 | if [ "$arch" = "x86_64" ]; then 6 | GOARCH="amd64" 7 | elif [ "$arch" = "aarch64" ]; then 8 | GOARCH="arm64" 9 | else 10 | echo "Unknown architecture: $arch" 11 | exit 1 12 | fi 13 | 14 | apt-get update 15 | apt-get install -y docker.io mysql-client-core-8.0 git 16 | update-alternatives --set editor /usr/bin/vim.basic 17 | systemctl enable docker 18 | systemctl start docker 19 | curl -sL https://github.com/docker/compose/releases/download/v2.4.1/docker-compose-linux-${arch} > /usr/local/bin/docker-compose 20 | chmod +x /usr/local/bin/docker-compose 21 | 22 | addgroup isucon 23 | adduser isucon --ingroup isucon --ingroup adm --ingroup docker --gecos "" --disabled-password 24 | mkdir /home/isucon/.ssh 25 | chown isucon:isucon /home/isucon/.ssh 26 | chmod 700 /home/isucon/.ssh 27 | echo 'isucon ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/isucon 28 | chmod 440 /etc/sudoers.d/isucon 29 | 30 | cd /tmp 31 | tar zxvf /tmp/isucon.tar.gz 32 | cd /tmp/isucon 33 | chown -R isucon:isucon ./ 34 | rsync -av ./ /home/isucon/ 35 | 36 | cd /home/isucon/bench 37 | ln -s bench.$GOARCH bench 38 | 39 | cd /home/isucon/webapp 40 | if [ "$arch" = "aarch64" ]; then 41 | perl -pi -e "s{mysql/mysql-server:8.0.28}{mysql/mysql-server:8.0.28-$arch}" docker-compose.yml 42 | fi 43 | chmod 1777 /home/isucon/webapp/mysql/logs 44 | docker-compose up --build -d 45 | set +xe 46 | while sleep 5; do 47 | echo . 48 | mysql -uroot -proot --host 127.0.0.1 --port 13306 -e "select now()" 2> /dev/null 49 | if [ $? -eq 0 ]; then 50 | break 51 | fi 52 | done 53 | curl -si localhost 54 | rm -rf /tmp/isucon* 55 | -------------------------------------------------------------------------------- /webapp/node/src/types/api.ts: -------------------------------------------------------------------------------- 1 | // API essential types 2 | export type Playlist = { 3 | ulid: string 4 | name: string 5 | user_display_name: string 6 | user_account: string 7 | song_count: number 8 | favorite_count: number 9 | is_favorited: boolean 10 | is_public: boolean 11 | created_at: Date 12 | updated_at: Date 13 | } 14 | 15 | export type PlaylistDetail = Playlist & { 16 | songs: Song[] 17 | } 18 | 19 | export type Song = { 20 | ulid: string 21 | title: string 22 | artist: string 23 | album: string 24 | track_number: number 25 | is_public: boolean 26 | } 27 | 28 | // API request types 29 | export type SignupRequest = { 30 | user_account: string 31 | password: string 32 | display_name: string 33 | } 34 | 35 | export type LoginRequest = { 36 | user_account: string 37 | password: string 38 | } 39 | 40 | export type AddPlaylistRequest = { 41 | name: string 42 | } 43 | 44 | export type UpdatePlaylistRequest = { 45 | name?: string 46 | song_ulids?: string[] 47 | is_public?: boolean 48 | } 49 | 50 | export type FavoritePlaylistRequest = { 51 | is_favorited: boolean 52 | } 53 | 54 | export type AdminPlayerBanRequest = { 55 | user_account: string 56 | is_ban: boolean 57 | } 58 | 59 | // API response types 60 | export type BasicResponse = { 61 | result: boolean 62 | status: number 63 | error?: string 64 | } 65 | 66 | export type GetRecentPlaylistsResponse = BasicResponse & { 67 | playlists: Playlist[] 68 | } 69 | 70 | export type GetPlaylistsResponse = BasicResponse & { 71 | created_playlists: Playlist[] 72 | favorited_playlists: Playlist[] 73 | } 74 | 75 | export type AddPlaylistResponse = BasicResponse & { 76 | playlist_ulid: string 77 | } 78 | 79 | export type SinglePlaylistResponse = BasicResponse & { 80 | playlist: PlaylistDetail 81 | } 82 | 83 | export type AdminPlayerBanResponse = BasicResponse & { 84 | user_account: string 85 | display_name: string 86 | is_ban: boolean 87 | created_at: Date 88 | } 89 | -------------------------------------------------------------------------------- /docs/scenario.md: -------------------------------------------------------------------------------- 1 | # ベンチシナリオ覚書 2 | 3 | ポイント 4 | 5 | - APIを網羅する 6 | - POST直後のGETで結果確認 7 | - 重複不可を守っていることをチェック 8 | 9 | ``` 10 | (もしあれば)静的ファイルチェック 11 | 12 | [ユーザー作成、ログイン] 13 | /api/signup (error, user_idに利用不可能な文字が含まれている) 14 | /api/signup (error, passwordに利用不可能な文字が含まれている) 15 | /api/signup (error, user_idが長すぎる) 16 | /api/signup (error, passwordが長すぎる) 17 | /api/signup (error, display_nameが長すぎる) 18 | /api/signup (OK) 19 | /api/login (error, パラメータ不足) 20 | /api/login (error, 存在しないユーザー) 21 | /api/login (error, password間違い) 22 | /api/login (OK) 23 | /api/logout (OK) 24 | /api/playlists (error, ログインセッション無し) 25 | /api/logout (error, ログインセッションなし) #TODO: cookieのセッション破棄させる処理しかないのであれば不要? 26 | /api/login (OK) 27 | /api/playlists (OK) 28 | 29 | [ 非ログイン時チェック ] 30 | /api/logout (ok) 31 | /api/playlists (error, ログインセッション無し) 32 | /api/playlist/add (error, ログインセッション無し) 33 | /api/playlist/{:xxx} (OK, リストの表示はログイン不要) 34 | /api/playlist/{:xxx}/update (error, ログインセッション無し) 35 | 36 | [ プレイリストの更新 ] 37 | /api/login (OK) 38 | /api/playlist/add (error, プレイリスト名が長すぎる) 39 | /api/playlist/add (OK) 40 | /api/playlists (OK, 新規プレイリストが追加されていることを確認) 41 | /api/playlist/{:xxx} (OK) 42 | /api/playlist/{:xxx}/update (OK, 初回の曲追加) 43 | /api/playlist/{:xxx} (OK, 内容があることを確認) 44 | /api/playlist/{:xxx}/update (OK, 内容の変更なし) 45 | /api/playlist/{:xxx}/update (error, 曲数が81曲以上) 46 | /api/playlist/{:xxx}/update (OK, 曲重複) 47 | /api/playlist/{:xxx}/update (OK, songsから曲削除) 48 | /api/playlist/{:xxx} (OK, 消した曲が入っていないことを確認) 49 | /api/playlist/{:xxx}/update (OK, 消した曲もう一度追加) 50 | /api/playlist/{:xxx} (OK, 入れた曲が入っていることを確認) 51 | /api/playlist/{:xxx}/update (OK, 曲の順序入れ替え) 52 | /api/playlist/{:xxx} (OK, 曲順が正しいことを確認) 53 | 54 | /api/playlist/{:xxx}/update (error, 他のユーザーのプレイリスト) 55 | 56 | [ プレイリストの削除 ] 57 | /api/playlist/{:xxx}/delete (OK) 58 | /api/playlist/{:xxx} (error, 存在しないプレイリスト) 59 | /api/playlists (OK, 消したプレイリストが存在しないことを確認) 60 | 61 | /api/playlist/{:xxx}/delete (error, 他のユーザーのプレイリスト) 62 | 63 | [ プレイリストのお気に入り] 64 | 65 | WIP 66 | ``` 67 | 68 | たくさん回してリクエスト数カウントしてスコアになるかなあ 69 | WIP 70 | -------------------------------------------------------------------------------- /docs/db_schema.md: -------------------------------------------------------------------------------- 1 | # DB schema 2 | 3 | - スキーマ表現上、 boolean は TINYINT(2) で定義します 4 | - numericなPrimary KeyはBIGINTにします(実用上、32bitでは足りないので!) 5 | - utf8mb4 を採用するので、Index Lengthを考慮してVARCHARのサイズは191文字にします 6 | 7 | ### user 8 | 9 | name | type | opts | note 10 | --- | --- | --- | --- 11 | account | varchar(191) | PRIMARY KEY | ユーザーが指定できるユーザーアカウント 12 | display_name | varchar(191) | | 13 | password_hash | varchar(191) | | 14 | is_ban | boolean | | BANされている(無効)アカウントかどうか 15 | created_at | timestamp | | ユーザーを作成した日時 16 | last_logined_at | timestamp | | ユーザーが最終ログイン日時 17 | 18 | ### song 19 | 20 | name | type | opts | note 21 | --- | --- | --- | --- 22 | id | bigint | PRIMARY KEY, AUTO_INCREMENT | 23 | ulid | varchar(191) | | ユーザーから見えるsongのID ULID 24 | title | varchar(191) | | 曲名 25 | artist_id | bigint | | artist tableのID 26 | album | varchar(191) | | アルバム名 27 | track_number | int | | アルバム内の曲順 28 | is_public | boolean | | 公開中かどうか 29 | 30 | ### artist 31 | 32 | name | type | opts | note 33 | --- | --- | --- | --- 34 | id | bigint | PRIMARY KEY, AUTO_INCREMENT | 35 | ulid | varchar(191) | | ユーザーから見えるartistのID ULID 36 | name | varchar(191) | | アーティスト名 37 | 38 | ### playlist 39 | 40 | name | type | opts | note 41 | --- | --- | --- | --- 42 | id | bigint | PRIMARY KEY, AUTO_INCREMENT | 43 | ulid | varchar(191) | | ユーザーから見えるplaylistのID ULID 44 | name | varchar(191) | | プレイリスト名 45 | user_acount | varchar(191) | | プレイリストを作成したユーザー 46 | url_string | varchar(191) | | プレイリストURL用の識別子 47 | is_public | boolean | | 公開中かどうか 48 | created_at | timestamp | | プレイリストを作成した日時 49 | updated_at | timestamp | | プレイリストを最終更新した日時 50 | 51 | ### playlist_song 52 | 53 | name | type | opts | note 54 | --- | --- | --- | --- 55 | playlist_id | bigint | PRIMARY KEY | 56 | sort_order | int | PRIMARY KEY | 57 | song_id | bigint | | 楽曲ID 58 | 59 | ### playlist_favorite 60 | 61 | name | type | opts | note 62 | --- | --- | --- | --- 63 | id | bigint | AUTO_INCREMENT | 64 | playlist_id | bigint | | 対象のプレイリストのID 65 | favorite_user_account | string | | プレイリストをふぁぼしたユーザー 66 | created_at | timestamp | | favした日時 67 | -------------------------------------------------------------------------------- /sql/50_listen80_schema.sql: -------------------------------------------------------------------------------- 1 | use isucon_listen80 2 | 3 | CREATE TABLE `user` ( 4 | `account` VARCHAR(191) NOT NULL, 5 | `display_name` VARCHAR(191) NOT NULL, 6 | `password_hash` VARCHAR(191) NOT NULL, 7 | `is_ban` TINYINT(2) NOT NULL, 8 | `created_at` TIMESTAMP(3) NOT NULL, 9 | `last_logined_at` TIMESTAMP(3) NOT NULL, 10 | PRIMARY KEY (`account`) 11 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 12 | 13 | CREATE TABLE `song` ( 14 | `id` BIGINT NOT NULL AUTO_INCREMENT, 15 | `ulid` VARCHAR(191) NOT NULL, 16 | `title` VARCHAR(191) NOT NULL, 17 | `artist_id` BIGINT NOT NULL, 18 | `album` VARCHAR(191) NOT NULL, 19 | `track_number` INT NOT NULL, 20 | `is_public` TINYINT(2) NOT NULL, 21 | PRIMARY KEY (`id`) 22 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 23 | 24 | CREATE TABLE `artist` ( 25 | `id` BIGINT NOT NULL AUTO_INCREMENT, 26 | `ulid` VARCHAR(191) NOT NULL, 27 | `name` VARCHAR(191) NOT NULL, 28 | PRIMARY KEY (`id`) 29 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 30 | 31 | CREATE TABLE `playlist` ( 32 | `id` BIGINT NOT NULL AUTO_INCREMENT, 33 | `ulid` VARCHAR(191) NOT NULL, 34 | `name` VARCHAR(191) NOT NULL, 35 | `user_account` VARCHAR(191) NOT NULL, 36 | `is_public` TINYINT(2) NOT NULL, 37 | `created_at` TIMESTAMP(3) NOT NULL, 38 | `updated_at` TIMESTAMP(3) NOT NULL, 39 | PRIMARY KEY (`id`) 40 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 41 | 42 | CREATE TABLE `playlist_song` ( 43 | `playlist_id` BIGINT NOT NULL, 44 | `sort_order` INT NOT NULL, 45 | `song_id` BIGINT NOT NULL, 46 | PRIMARY KEY (`playlist_id`, `sort_order`) 47 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 48 | 49 | CREATE TABLE `playlist_favorite` ( 50 | `id` BIGINT NOT NULL AUTO_INCREMENT, 51 | `playlist_id` BIGINT NOT NULL, 52 | `favorite_user_account` VARCHAR(191) NOT NULL, 53 | `created_at` TIMESTAMP(3) NOT NULL, 54 | PRIMARY KEY (`id`), 55 | UNIQUE `uniq_playlist_id_favorite_user_account` (`playlist_id`, `favorite_user_account`) 56 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 57 | 58 | CREATE TABLE IF NOT EXISTS `sessions` ( 59 | `session_id` varchar(128) COLLATE utf8mb4_bin NOT NULL, 60 | `expires` int(11) unsigned NOT NULL, 61 | `data` mediumtext COLLATE utf8mb4_bin, 62 | PRIMARY KEY (`session_id`) 63 | ) ENGINE=InnoDB 64 | -------------------------------------------------------------------------------- /webapp/public/assets/js/playlist.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', () => { 2 | 3 | const getPlaylist = async () => { 4 | const playlistUlid = document.getElementById('playlist-id').getAttribute('value') 5 | 6 | const response = await fetch('/api/playlist/' + playlistUlid) 7 | const json = await response.json() 8 | if (!json.result) { 9 | alert('プレイリスト取得失敗 (他人の非公開プレイリストかも?)') 10 | } 11 | 12 | const playlist = json.playlist 13 | const createdAt = new Date(Date.parse(playlist.created_at)).toLocaleString() 14 | const updatedAt = new Date(Date.parse(playlist.updated_at)).toLocaleString() 15 | const favstat = playlist.is_favorited ? 'fav' : '' 16 | const visibility = playlist.is_public ? '公開' : '非公開' 17 | 18 | const metadata = document.getElementById('playlist-metadata') 19 | 20 | metadata.innerHTML = ` 21 | 22 |

${playlist.name} 23 |
24 | 25 | 26 | 27 |

28 | 37 | ` 38 | 39 | const tbody = document.getElementById('songs-table-body') 40 | 41 | const rowHTML = playlist.songs.map((song, index) => { 42 | return ` 43 | 44 | ${index+1} 45 | ${song.title} 46 | ${song.artist} 47 | ${song.album} 48 | ${song.track_number} 49 | 50 | ` 51 | }) 52 | 53 | tbody.innerHTML = rowHTML.join('\n') 54 | 55 | return response 56 | } 57 | 58 | const main = document.getElementById('main-app') 59 | main.addEventListener('refreshRequired', () => { 60 | getPlaylist() 61 | }) 62 | 63 | getPlaylist() 64 | }) 65 | -------------------------------------------------------------------------------- /webapp/public/assets/js/mypage.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', () => { 2 | const myPlaylists = async () => { 3 | const my = document.getElementById('my-playlists') 4 | const fav = document.getElementById('fav-playlists') 5 | fav.innerHTML = ` 6 |
7 | 8 |
` 9 | 10 | const response = await fetch('/api/playlists') 11 | const json = await response.json() 12 | if (!json.result) { 13 | alert('自分のプレイリスト取得失敗 (ログインしてますか?)') 14 | } 15 | 16 | my.innerHTML = json.created_playlists.map((x) => playlistToHTML(x, true)).join('\n') 17 | 18 | fav.innerHTML = json.favorited_playlists.map((x) => playlistToHTML(x, false)).join('\n') 19 | 20 | return response 21 | } 22 | 23 | const addPlaylist = async (title) => { 24 | const response = await fetch(`/api/playlist/add`, { 25 | method: 'POST', 26 | headers: { 27 | 'Content-Type': 'application/json', 28 | }, 29 | body: JSON.stringify({ 30 | name: title, 31 | }) 32 | }) 33 | const json = await response.json() 34 | if (!json.result) { 35 | alert('追加失敗 (ログインしてますか?)') 36 | } 37 | return response 38 | } 39 | 40 | 41 | 42 | const main = document.getElementById('main-app') 43 | main.addEventListener('refreshRequired', () => { 44 | myPlaylists() 45 | }) 46 | 47 | const addButton = document.getElementById('add-playlist') 48 | addButton.addEventListener('click', async () => { 49 | const message = window.prompt("新しいプレイリストの名前を入力") 50 | if (!message) { 51 | // キャンセルした(null)か、空文字でOKした 52 | return 53 | } 54 | 55 | await addPlaylist(message) 56 | 57 | const event = new Event('refreshRequired') 58 | const elem = document.getElementById('main-app') 59 | elem.dispatchEvent(event) 60 | }) 61 | 62 | myPlaylists() 63 | }) 64 | 65 | -------------------------------------------------------------------------------- /webapp/public/assets/js/top.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', () => { 2 | 3 | const recentActivities = async () => { 4 | const recent = document.getElementById('recent-playlists') 5 | recent.innerHTML = ` 6 |
7 | 8 |
` 9 | const response = await fetch('/api/recent_playlists') 10 | const json = await response.json() 11 | if (!json.result) { 12 | alert('最近のプレイリスト取得失敗 (サイトが重いかも?)') 13 | } 14 | 15 | recent.innerHTML = json.playlists.map((x) => playlistToHTML(x, false)).join('\n') 16 | 17 | return response 18 | } 19 | 20 | const popularActivities = async () => { 21 | const popular = document.getElementById('popular-playlists') 22 | popular.innerHTML = ` 23 |
24 | 25 |
` 26 | const response = await fetch('/api/popular_playlists') 27 | const json = await response.json() 28 | if (!json.result) { 29 | alert('人気のプレイリスト取得失敗 (サイトが重いかも?)') 30 | } 31 | 32 | popular.innerHTML = json.playlists.map((x) => playlistToHTML(x, false)).join('\n') 33 | 34 | return response 35 | } 36 | 37 | const main = document.getElementById('main-app') 38 | main.addEventListener('refreshRequired', () => { 39 | recentActivities() 40 | popularActivities() 41 | }) 42 | 43 | recentActivities() 44 | popularActivities() 45 | }) 46 | 47 | -------------------------------------------------------------------------------- /bench/models.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "sync" 8 | "time" 9 | 10 | "encoding/json" 11 | 12 | "github.com/isucon/isucandar/agent" 13 | "github.com/samber/lo" 14 | ) 15 | 16 | type Model interface { 17 | User | Song 18 | } 19 | 20 | func LoadFromJSONFile[T Model](jsonFile string) ([]*T, error) { 21 | // 引数に渡されたファイルを開く 22 | file, err := os.Open(jsonFile) 23 | if err != nil { 24 | return nil, err 25 | } 26 | defer file.Close() 27 | 28 | objects := make([]*T, 0, 10000) // 大きく確保しておく 29 | // JSON 形式としてデコード 30 | decoder := json.NewDecoder(file) 31 | if err := decoder.Decode(&objects); err != nil { 32 | if err != io.EOF { 33 | return nil, fmt.Errorf("failed to decode json: %w", err) 34 | } 35 | } 36 | return objects, nil 37 | } 38 | 39 | type Users []*User 40 | 41 | func (us Users) Choice() *User { 42 | return lo.Sample(us) 43 | } 44 | 45 | type User struct { 46 | mu sync.RWMutex 47 | 48 | Account string `json:"account"` 49 | Password string `json:"password"` 50 | DisplayName string `json:"display_name"` 51 | IsBan bool `json:"is_ban"` 52 | IsHeavy bool `json:"is_heavy"` 53 | 54 | Agent *agent.Agent 55 | } 56 | 57 | func (u *User) GetAgent(o Option) (*agent.Agent, error) { 58 | u.mu.RLock() 59 | a := u.Agent 60 | u.mu.RUnlock() 61 | if a != nil { 62 | return a, nil 63 | } 64 | 65 | u.mu.Lock() 66 | defer u.mu.Unlock() 67 | 68 | a, err := o.NewAgent(false) 69 | if err != nil { 70 | return nil, err 71 | } 72 | u.Agent = a 73 | return a, nil 74 | } 75 | 76 | type Song struct { 77 | ULID string `json:"ulid"` 78 | Title string `json:"title"` 79 | ArtistName string `json:"artist_name"` 80 | ArtistID int64 `json:"artist_id"` 81 | } 82 | 83 | type Songs []*Song 84 | 85 | func (ss Songs) ULIDs() []string { 86 | ids := make([]string, 0, len(ss)) 87 | for _, s := range ss { 88 | ids = append(ids, s.ULID) 89 | } 90 | return ids 91 | } 92 | 93 | func GenerateUserAccount() string { 94 | return fmt.Sprintf("user-%s", newULID()) 95 | } 96 | 97 | type Playlist struct { 98 | ULID string `json:"ulid"` 99 | Name string `json:"name"` 100 | UserDisplayName string `json:"user_display_name"` 101 | UserAccount string `json:"user_account"` 102 | SongCount int `json:"song_count"` 103 | Songs Songs `json:"-"` 104 | FavoriteCount int `json:"favorite_count"` 105 | IsFavorited bool `json:"is_favorited"` 106 | IsPublic bool `json:"is_public"` 107 | CreatedAt time.Time `json:"created_at"` 108 | UpdatedAt time.Time `json:"updated_at"` 109 | } 110 | -------------------------------------------------------------------------------- /webapp/golang/api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | // API essential types 6 | 7 | type Playlist struct { 8 | ULID string `json:"ulid"` 9 | Name string `json:"name"` 10 | UserDisplayName string `json:"user_display_name"` 11 | UserAccount string `json:"user_account"` 12 | SongCount int `json:"song_count"` 13 | FavoriteCount int `json:"favorite_count"` 14 | IsFavorited bool `json:"is_favorited"` 15 | IsPublic bool `json:"is_public"` 16 | CreatedAt time.Time `json:"created_at"` 17 | UpdatedAt time.Time `json:"updated_at"` 18 | } 19 | 20 | type PlaylistDetail struct { 21 | *Playlist 22 | Songs []Song `json:"songs"` 23 | } 24 | 25 | type Song struct { 26 | ULID string `json:"ulid"` 27 | Title string `json:"title"` 28 | Artist string `json:"artist"` 29 | Album string `json:"album"` 30 | TrackNumber int `json:"track_number"` 31 | IsPublic bool `json:"is_public"` 32 | } 33 | 34 | // API request types 35 | 36 | type SignupRequest struct { 37 | UserAccount string `json:"user_account"` 38 | Password string `json:"password"` 39 | DisplayName string `json:"display_name"` 40 | } 41 | 42 | type LoginRequest struct { 43 | UserAccount string `json:"user_account"` 44 | Password string `json:"password"` 45 | } 46 | 47 | type AddPlaylistRequest struct { 48 | Name string `json:"name"` 49 | } 50 | 51 | type UpdatePlaylistRequest struct { 52 | Name *string `json:"name"` 53 | SongULIDs []string `json:"song_ulids,omitempty"` 54 | IsPublic bool `json:"is_public"` 55 | } 56 | 57 | type FavoritePlaylistRequest struct { 58 | IsFavorited bool `json:"is_favorited"` 59 | } 60 | 61 | type AdminPlayerBanRequest struct { 62 | UserAccount string `json:"user_account"` 63 | IsBan bool `json:"is_ban"` 64 | } 65 | 66 | // API response types 67 | 68 | type BasicResponse struct { 69 | Result bool `json:"result"` 70 | Status int `json:"status"` 71 | Error *string `json:"error,omitempty"` 72 | } 73 | 74 | type GetRecentPlaylistsResponse struct { 75 | BasicResponse 76 | Playlists []Playlist `json:"playlists"` 77 | } 78 | 79 | type GetPlaylistsResponse struct { 80 | BasicResponse 81 | CreatedPlaylists []Playlist `json:"created_playlists"` 82 | FavoritedPlaylists []Playlist `json:"favorited_playlists"` 83 | } 84 | 85 | type AddPlaylistResponse struct { 86 | BasicResponse 87 | PlaylistULID string `json:"playlist_ulid"` 88 | } 89 | 90 | type SinglePlaylistResponse struct { 91 | BasicResponse 92 | Playlist PlaylistDetail `json:"playlist"` 93 | } 94 | 95 | type AdminPlayerBanResponse struct { 96 | BasicResponse 97 | UserAccount string `json:"user_account"` 98 | DisplayName string `json:"display_name"` 99 | IsBan bool `json:"is_ban"` 100 | CreatedAt time.Time `json:"created_at"` 101 | } 102 | -------------------------------------------------------------------------------- /packer/isucon.pkr.hcl: -------------------------------------------------------------------------------- 1 | packer { 2 | required_plugins { 3 | amazon = { 4 | version = ">= 0.0.2" 5 | source = "github.com/hashicorp/amazon" 6 | } 7 | } 8 | } 9 | 10 | source "amazon-ebs" "isucon-x86_64" { 11 | ami_name = "kayac-isucon-2022-${formatdate("YYYYMMDD-hhmm", timestamp())}-x86_64" 12 | instance_type = "t3.medium" 13 | region = "ap-northeast-1" 14 | 15 | source_ami_filter { 16 | filters = { 17 | name = "ubuntu/images/*ubuntu-focal-20.04-amd64-server-*" 18 | root-device-type = "ebs" 19 | virtualization-type = "hvm" 20 | } 21 | most_recent = true 22 | owners = ["099720109477"] 23 | } 24 | 25 | ssh_username = "ubuntu" 26 | encrypt_boot = false 27 | 28 | launch_block_device_mappings { 29 | device_name = "/dev/sda1" 30 | volume_type = "gp3" 31 | volume_size = 16 32 | delete_on_termination = true 33 | } 34 | } 35 | 36 | source "amazon-ebs" "isucon-aarch64" { 37 | ami_name = "kayac-isucon-2022-${formatdate("YYYYMMDD-hhmm", timestamp())}-aarch64" 38 | instance_type = "t4g.medium" 39 | region = "ap-northeast-1" 40 | 41 | source_ami_filter { 42 | filters = { 43 | name = "ubuntu/images/*ubuntu-focal-20.04-arm64-server-*" 44 | root-device-type = "ebs" 45 | virtualization-type = "hvm" 46 | } 47 | most_recent = true 48 | owners = ["099720109477"] 49 | } 50 | 51 | ssh_username = "ubuntu" 52 | encrypt_boot = false 53 | 54 | launch_block_device_mappings { 55 | device_name = "/dev/sda1" 56 | volume_type = "gp3" 57 | volume_size = 16 58 | delete_on_termination = true 59 | } 60 | } 61 | 62 | build { 63 | name = "isucon-x86_64" 64 | sources = [ 65 | "source.amazon-ebs.isucon-x86_64" 66 | ] 67 | 68 | provisioner "file" { 69 | source = "isucon.tar.gz" 70 | destination = "/tmp/isucon.tar.gz" 71 | } 72 | 73 | provisioner "file" { 74 | source = "provisioning.sh" 75 | destination = "/tmp/provisioning.sh" 76 | } 77 | 78 | provisioner "shell" { 79 | inline = [ 80 | "sleep 10", 81 | "sudo /tmp/provisioning.sh", 82 | "sudo rm -fr /tmp/isucon* /tmp/provisioning.sh", 83 | ] 84 | } 85 | } 86 | 87 | build { 88 | name = "isucon-aarch64" 89 | sources = [ 90 | "source.amazon-ebs.isucon-aarch64" 91 | ] 92 | 93 | provisioner "file" { 94 | source = "isucon.tar.gz" 95 | destination = "/tmp/isucon.tar.gz" 96 | } 97 | 98 | provisioner "file" { 99 | source = "provisioning.sh" 100 | destination = "/tmp/provisioning.sh" 101 | } 102 | 103 | provisioner "shell" { 104 | inline = [ 105 | "sleep 10", 106 | "sudo /tmp/provisioning.sh", 107 | "sudo rm -fr /tmp/isucon* /tmp/provisioning.sh", 108 | ] 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /bench/scenario_fav.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | 7 | "github.com/isucon/isucandar" 8 | "github.com/isucon/isucandar/worker" 9 | ) 10 | 11 | func (s *Scenario) FavoriteWorker(step *isucandar.BenchmarkStep, p int32) (*worker.Worker, error) { 12 | w, err := worker.NewWorker(func(ctx context.Context, _ int) { 13 | s.FavoriteScenario(ctx, step) 14 | }, 15 | // 無限回繰り返す 16 | worker.WithInfinityLoop(), 17 | worker.WithUnlimitedParallelism(), 18 | ) 19 | if err != nil { 20 | return nil, err 21 | } 22 | w.SetParallelism(p) 23 | return w, nil 24 | } 25 | 26 | // 新着Playlistにfav/add爆撃をするシナリオ 27 | func (s *Scenario) FavoriteScenario(ctx context.Context, step *isucandar.BenchmarkStep) error { 28 | report := timeReporter("favorite") 29 | defer report() 30 | 31 | // fav爆をするのはヘビーユーザー 32 | user, release := s.ChoiceUser(ctx, s.HeavyUsers) 33 | defer release() 34 | if user == nil { 35 | return nil 36 | } 37 | ag, _ := user.GetAgent(s.Option) 38 | { 39 | // ログイン 40 | res, err := LoginAction(ctx, user, ag) 41 | v := ValidateResponse("ログイン", 42 | step, res, err, 43 | WithStatusCode(200), 44 | WithSuccessResponse[ResponseAPIBase](), 45 | ) 46 | if v.IsEmpty() { 47 | step.AddScore(ScoreLogin) 48 | } else { 49 | return v 50 | } 51 | } 52 | var ULIDS []string 53 | { 54 | // 最新プレイリスト一覧 55 | res, err := GetRecentPlaylistsAction(ctx, ag) 56 | v := ValidateResponse("最新プレイリスト一覧", 57 | step, res, err, 58 | WithStatusCode(200), 59 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 60 | for _, p := range r.Playlists { 61 | if !p.IsFavorited && rand.Int()%4 == 0 { // 25%の確率で 62 | ULIDS = append(ULIDS, p.ULID) 63 | } 64 | } 65 | return nil 66 | }), 67 | ) 68 | if v.IsEmpty() { 69 | step.AddScore(ScoreGetRecentPlaylistsLogin) 70 | } else { 71 | return v 72 | } 73 | } 74 | if rand.Int31n(100) < s.RateGetPopularPlaylists() { // popularは確率で取る 75 | // 人気プレイリスト一覧 76 | res, err := GetPopularPlaylistsAction(ctx, ag) 77 | v := ValidateResponse("人気プレイリスト一覧", 78 | step, res, err, 79 | WithStatusCode(200), 80 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 81 | for _, p := range r.Playlists { 82 | if !p.IsFavorited && rand.Int()%4 == 0 { // 25%の確率で 83 | ULIDS = append(ULIDS, p.ULID) 84 | } 85 | } 86 | return nil 87 | }), 88 | ) 89 | if v.IsEmpty() { 90 | step.AddScore(ScoreGetRecentPlaylistsLogin) 91 | } else { 92 | return v 93 | } 94 | } 95 | for _, id := range ULIDS { 96 | res, err := FavoritePlaylistAction(ctx, &Playlist{ULID: id, IsFavorited: true}, ag) 97 | v := ValidateResponse( 98 | "プレイリストをfavする", 99 | step, res, err, 100 | WithStatusCode(200, 404), // banされたときは404になるのでどっちでも許容 101 | ) 102 | v.Add(step) 103 | if v.IsEmpty() { 104 | step.AddScore(ScoreFavoritePlaylist) 105 | } else { 106 | return v 107 | } 108 | } 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /bench/scenario_admin.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/isucon/isucandar" 9 | "github.com/isucon/isucandar/worker" 10 | "github.com/samber/lo" 11 | ) 12 | 13 | func (s *Scenario) AdminWorker(step *isucandar.BenchmarkStep, _ int32) (*worker.Worker, error) { 14 | w, err := worker.NewWorker(func(ctx context.Context, _ int) { 15 | timer := time.NewTimer(3 * time.Second) 16 | select { 17 | case <-ctx.Done(): 18 | case <-timer.C: 19 | } 20 | s.AdminScenario(ctx, step) 21 | }, 22 | // 無限回繰り返す 23 | worker.WithInfinityLoop(), 24 | worker.WithMaxParallelism(1), 25 | ) 26 | if err != nil { 27 | return nil, err 28 | } 29 | w.SetParallelism(1) 30 | return w, nil 31 | } 32 | 33 | // AdminUserのシナリオ 34 | func (s *Scenario) AdminScenario(ctx context.Context, step *isucandar.BenchmarkStep) error { 35 | report := timeReporter("admin") 36 | defer report() 37 | 38 | adminAg, _ := s.AdminUser.GetAgent(s.Option) 39 | { 40 | // admin user ログイン 41 | admin := s.AdminUser 42 | res, err := LoginAction(ctx, admin, adminAg) 43 | v := ValidateResponse( 44 | "adminログイン", 45 | step, res, err, 46 | WithStatusCode(200), 47 | ) 48 | if !v.IsEmpty() { 49 | return v 50 | } 51 | } 52 | var banPlayList Playlist 53 | { 54 | // RecentPlaylistを取得 55 | res, err := GetRecentPlaylistsAction(ctx, adminAg) 56 | v := ValidateResponse("最新プレイリスト一覧を取得", 57 | step, res, err, 58 | WithStatusCode(200), 59 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 60 | if len(r.Playlists) != 100 { 61 | return fmt.Errorf("最新プレイリストには100曲あるはずですが、%d曲しかありません", len(r.Playlists)) 62 | } 63 | banPlayList = lo.Sample(r.Playlists) 64 | return nil 65 | }), 66 | ) 67 | if !v.IsEmpty() { 68 | return v 69 | } 70 | } 71 | { 72 | // admin ban 73 | banUser := &User{ 74 | Account: banPlayList.UserAccount, 75 | } 76 | res, err := AdminBanAction(ctx, banUser, true, adminAg) 77 | v := ValidateResponse( 78 | "admin ban", 79 | step, res, err, 80 | WithStatusCode(200), 81 | WithSuccessResponse(func(r ResponseAdminBan) error { 82 | if r.UserAccount != banUser.Account { 83 | return fmt.Errorf("banしたユーザーが正しくありません") 84 | } 85 | if !r.IsBan { 86 | return fmt.Errorf("banが正常に実行されていません") 87 | } 88 | return nil 89 | }), 90 | ) 91 | if !v.IsEmpty() { 92 | return v 93 | } 94 | } 95 | // ban操作のあと3秒は猶予がある 96 | time.Sleep(3 * time.Second) 97 | { 98 | // プレイリスト詳細が404になる 99 | res, err := GetPlaylistAction(ctx, banPlayList.ULID, adminAg) 100 | v := ValidateResponse( 101 | "banされたプレイリスト詳細は404", 102 | step, res, err, 103 | WithStatusCode(404), 104 | ) 105 | if !v.IsEmpty() { 106 | return v 107 | } 108 | } 109 | { 110 | // admin unban 111 | banUser := &User{ 112 | Account: banPlayList.UserAccount, 113 | } 114 | res, err := AdminBanAction(ctx, banUser, false, adminAg) 115 | v := ValidateResponse( 116 | "ban解除", 117 | step, res, err, 118 | WithStatusCode(200), 119 | WithSuccessResponse(func(r ResponseAdminBan) error { 120 | if r.UserAccount != banUser.Account { 121 | return fmt.Errorf("ban解除したユーザーが正しくありません") 122 | } 123 | if r.IsBan { 124 | return fmt.Errorf("ban解除が正常に実行されていません") 125 | } 126 | return nil 127 | }), 128 | ) 129 | if !v.IsEmpty() { 130 | return v 131 | } 132 | } 133 | // ban操作のあと3秒は猶予がある 134 | time.Sleep(3 * time.Second) 135 | { 136 | // プレイリスト詳細が200になる 137 | res, err := GetPlaylistAction(ctx, banPlayList.ULID, adminAg) 138 | v := ValidateResponse( 139 | "プレイリスト詳細", 140 | step, res, err, 141 | WithStatusCode(200), 142 | ) 143 | if !v.IsEmpty() { 144 | return v 145 | } 146 | } 147 | 148 | return nil 149 | } 150 | -------------------------------------------------------------------------------- /bench/cmd/httpd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/Songmu/timeout" 17 | "github.com/aws/aws-sdk-go/aws" 18 | "github.com/aws/aws-sdk-go/aws/session" 19 | "github.com/aws/aws-sdk-go/service/cloudwatch" 20 | "github.com/fujiwara/ridge" 21 | "github.com/natureglobal/realip" 22 | "golang.org/x/net/context" 23 | ) 24 | 25 | var middleware func(http.Handler) http.Handler 26 | 27 | var scoreRegexp = regexp.MustCompile(`SCORE: (\d+)`) 28 | 29 | func init() { 30 | var ipnets []*net.IPNet 31 | for _, n := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.1/32"} { 32 | _, ipnet, err := net.ParseCIDR(n) 33 | if err != nil { 34 | panic(err) 35 | } 36 | ipnets = append(ipnets, ipnet) 37 | } 38 | middleware = realip.MustMiddleware(&realip.Config{ 39 | RealIPFrom: ipnets, 40 | RealIPHeader: realip.HeaderXForwardedFor, 41 | RealIPRecursive: true, 42 | }) 43 | } 44 | 45 | func main() { 46 | var mux = http.NewServeMux() 47 | mux.HandleFunc("/bench", handleBench) 48 | mux.HandleFunc("/", handleRoot) 49 | ridge.Run(":8080", "/", middleware(mux)) 50 | } 51 | 52 | func handleRoot(w http.ResponseWriter, r *http.Request) { 53 | w.Header().Set("Content-Type", "text/plain") 54 | host := r.Header.Get(realip.HeaderXRealIP) 55 | fmt.Fprintln(w, "Hello "+host) 56 | fmt.Fprintln(w, "TAG "+os.Getenv("TAG")) 57 | } 58 | 59 | func handleBench(w http.ResponseWriter, r *http.Request) { 60 | if r.Method != "POST" { 61 | w.WriteHeader(http.StatusMethodNotAllowed) 62 | return 63 | } 64 | host := r.Header.Get(realip.HeaderXRealIP) 65 | target := fmt.Sprintf("http://%s", host) 66 | teamID := r.FormValue("team_id") 67 | if teamID == "" { 68 | w.WriteHeader(http.StatusBadRequest) 69 | fmt.Fprintln(w, "team_id is required") 70 | return 71 | } 72 | 73 | args := []string{"-target-url", target} 74 | log.Println("start bench", args) 75 | tio := &timeout.Timeout{ 76 | Cmd: exec.Command("./bench", args...), 77 | Duration: 120 * time.Second, 78 | KillAfter: 5 * time.Second, 79 | } 80 | exitStatus, stdout, stderr, err := tio.Run() 81 | 82 | w.Header().Set("Content-Type", "text/plain") 83 | for _, o := range []io.Writer{w, os.Stdout} { 84 | fmt.Fprintln(o, "bench", strings.Join(args, " ")) 85 | fmt.Fprintln(o, "exit status:", exitStatus) 86 | if err != nil { 87 | fmt.Fprintln(o, "error:", err) 88 | } 89 | fmt.Fprintln(o, stdout) 90 | } 91 | fmt.Fprintln(os.Stderr, stderr) 92 | 93 | score, err := parseScore(stdout) 94 | if err != nil { 95 | log.Println("failed to parse score:", err) 96 | fmt.Fprintln(w, err) 97 | return 98 | } 99 | if err := postScore(teamID, score); err != nil { 100 | log.Println("failed to post score:", err) 101 | fmt.Fprintln(w, err) 102 | } 103 | } 104 | 105 | func parseScore(s string) (int64, error) { 106 | m := scoreRegexp.FindStringSubmatch(s) 107 | if m == nil { 108 | return -1, fmt.Errorf("score is not found in stdout") 109 | } 110 | return strconv.ParseInt(m[1], 10, 64) 111 | } 112 | 113 | func postScore(teamID string, score int64) error { 114 | sess := session.Must(session.NewSession()) 115 | svc := cloudwatch.New(sess) 116 | in := &cloudwatch.PutMetricDataInput{ 117 | Namespace: aws.String("isucon"), 118 | MetricData: []*cloudwatch.MetricDatum{{ 119 | MetricName: aws.String("score"), 120 | Timestamp: aws.Time(time.Now()), 121 | Value: aws.Float64(float64(score)), 122 | Dimensions: []*cloudwatch.Dimension{{ 123 | Name: aws.String("team_id"), 124 | Value: aws.String(teamID), 125 | }}, 126 | }}, 127 | } 128 | 129 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 130 | defer cancel() 131 | _, err := svc.PutMetricDataWithContext(ctx, in) 132 | 133 | return err 134 | } 135 | -------------------------------------------------------------------------------- /webapp/public/assets/css/listen80.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0 auto; 3 | max-width: 1200px; 4 | padding: 0 20px 0; 5 | } 6 | 7 | nav { 8 | padding: 10px; 9 | display: flex; 10 | background: rgb(155, 225, 207); 11 | border-bottom-right-radius: 5px; 12 | border-bottom-left-radius: 5px; 13 | } 14 | 15 | nav h1 { 16 | font-size: 2em; 17 | margin: 0 30px 0 0; 18 | } 19 | 20 | nav .nav-link { 21 | padding: auto 0; 22 | } 23 | 24 | nav .nav-support { 25 | text-align: right; 26 | padding: auto 0; 27 | flex-grow: 1; 28 | } 29 | 30 | 31 | nav a { 32 | font-weight: bold; 33 | color: #2c3e50; 34 | } 35 | 36 | 37 | .signup-view, .login-view { 38 | text-align: center; 39 | } 40 | 41 | .signup-view label, 42 | .login-view label { 43 | text-align: right; 44 | display: inline-block; 45 | width: 8em; 46 | } 47 | 48 | .playlists-container { 49 | display: flex; 50 | flex-wrap: wrap; 51 | } 52 | 53 | 54 | .playlist-card { 55 | border-radius: 6px; 56 | width: 380px; 57 | padding: 0px; 58 | margin: 0px 10px 10px 0; 59 | } 60 | 61 | .playlist-card .playlist-card-heading{ 62 | padding: 10px; 63 | background-color:rgb(104, 178, 12); 64 | position: relative; 65 | border-top-left-radius: 6px; 66 | border-top-right-radius: 6px; 67 | } 68 | .playlist-card-heading h3 { 69 | padding: 0em; 70 | margin: 0; 71 | border: 0; 72 | font-size: 18px; 73 | } 74 | 75 | .playlist-card-heading h3 a { 76 | color: #FFF; 77 | } 78 | .playlist-card-heading .love-button { 79 | text-align: right; 80 | position: absolute; 81 | top: 8px; 82 | right: 10px; 83 | color: #FFF; 84 | } 85 | 86 | 87 | 88 | .love-button a { 89 | color: rgb(206, 206, 206); 90 | } 91 | .playlist-card-heading .love-button a { 92 | color: #FFF; 93 | } 94 | 95 | .love-button a.fav { 96 | color: rgb(218, 72, 97); 97 | text-shadow: 98 | 1px 1px 0px #FFF, -1px -1px 0px #FFF, 99 | -1px 1px 0px #FFF, 1px -1px 0px #FFF, 100 | 1px 0px 0px #FFF, -1px 0px 0px #FFF, 101 | 0px 1px 0px #FFF, 0px -1px 0px #FFF; 102 | stroke: #FFF; 103 | } 104 | 105 | h3 .love-button { 106 | float: right; 107 | } 108 | 109 | .large { 110 | font-size: 2em; 111 | } 112 | 113 | .playlist-card-body { 114 | background-color:rgb(237, 242, 230); 115 | font-size: 14px; 116 | padding: 10px 10px 5px; 117 | border-bottom-left-radius: 6px; 118 | border-bottom-right-radius: 6px; 119 | } 120 | 121 | .playlist-card .date { 122 | font-size: 0.75em; 123 | color: rgb(155, 164, 161); 124 | clear: both; 125 | margin-top: 5px; 126 | padding-top: 5px; 127 | border-top: 1px #EEEEEE solid; 128 | } 129 | 130 | .playlist-card-body * label { 131 | display: inline-block; 132 | width: 80px; 133 | text-align: right; 134 | } 135 | 136 | .playlist-art { 137 | float: right; 138 | border: 1px solid lightgray; 139 | width: 64px; 140 | height: 64px; 141 | background-color: #EEEEEE; 142 | background-image: url('data:image/svg+xml;utf8,'); 143 | background-size: 40px 40px; 144 | background-repeat: no-repeat; 145 | background-position: 50%; 146 | } 147 | 148 | .action-box { 149 | margin: 1em 0; 150 | } 151 | 152 | a.action-button:hover { 153 | cursor: pointer; 154 | } 155 | 156 | @keyframes blur { 157 | 0% { 158 | opacity: 100%; 159 | transform: scale(1); 160 | } 161 | 100% { 162 | opacity: 10%; 163 | transform: scale(2); 164 | } 165 | } 166 | 167 | .loading { 168 | margin-top: 50px; 169 | margin-bottom: 50px; 170 | text-align: center; 171 | animation: blur 0.5s infinite; 172 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 社内ISUCON 2022 2 | 3 | 面白法人カヤックの社内ISUCON 2022年版です。 4 | 5 | 開催報告blog [カヤック×PR TIMES合同 カヤック社内ISUCONを開催しました](https://techblog.kayac.com/inhouse-isucon-2022) 6 | 7 | ![](docs/listen80.png) 8 | 9 | ## 用意されている参考実装 10 | 11 | - Node.JS (TypeScript) 12 | - Go 13 | 14 | ## レギュレーション & 当日マニュアル 15 | 16 | [docs/README.md](docs/README.md) 17 | 18 | ## 起動方法 19 | 20 | ### Docker Compose 21 | 22 | まずこのリポジトリをcloneし、実行に必要なデータを取得するために `make dataset` を実行します。 23 | 24 | その後、`webapp` ディレクトリで Docker Compose によって起動できます。 25 | 26 | ```console 27 | $ git clone https://github.com/kayac/kayac-isucon-2022.git 28 | $ make dataset 29 | $ cd webapp 30 | $ docker-compose up --build 31 | ``` 32 | 33 | 初期状態では Node.JS (TypeScript) 実装が起動します。 34 | 35 | - Go実装に切り替える場合は [docker-compose.yml](webapp/docker-compose.yml) のコメントを参照してください 36 | - M1 mac (ARM) で動作させる場合、mysqlコンテナを `image: mysql/mysql-server:8.0.28-aarch64` に変更して下さい 37 | 38 | 初回起動時にはMySQLへデータを読み込むため、起動まで数分かかります。 39 | 40 | Go実装は、初回起動時にMySQLに接続できずに異常終了してしまうことがあります。その場合は初回のmysqlコンテナの起動が完了したら、Docker Composeを再起動して下さい。 41 | 42 | マニュアル [docs/README.md](docs/README.md) も参照して下さい。 43 | 44 | ### Amazon EC2 AMI 45 | 46 | AWS ap-northeast-1 (東京リージョン) で、以下のAMIからEC2を起動してください。 47 | 48 | AMI ID | AMI name | アーキテクチャ 49 | --------|------|---- 50 | ami-06224cd9a615efa7e | kayac-isucon-2022-20220516-0209-x86_64 | X86_64 51 | ami-03d15acedbdf56eab | kayac-isucon-2022-20220516-0209-aarch64 | ARM64 (aarch64) 52 | 53 | - TCP port 80 (必要ならSSH用にport 22) を必要に応じて開放してください 54 | - 初期状態で ssm-agent が起動しています 55 | - 適切なインスタンスprofileを指定するとSSM Session Managerでログインできるため、sshは必須ではありません 56 | - SSHでログインする場合、`ubuntu` ユーザーが使用できます 57 | - インスタンスタイプの想定は c6i.xlarge です 58 | - 社内ISUCON開催時のスペックです。c6i.largeなど、2コアのインスタンスでも動作は可能です 59 | - 競技用に `isucon` ユーザーが存在します 60 | - `/home/isucon` 以下にこのリポジトリが配置されています 61 | - Docker Compose でアプリケーション一式が起動しています 62 | - AMIからインスタンスを起動した直後は、EBS volume の "first touch penalty" のためディスクの読み取りが低速で、ベンチマークが正常に完了しないことがあります 63 | - 参考 [Amazon EBS ボリュームの初期化](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/ebs-initialize.html) 64 | - 起動後に以下の手順でインスタンス上のデータベースファイルを一度読み捨てることで、正常なパフォーマンスを発揮できるようになります 65 | ```console 66 | $ sudo -s 67 | # cat /var/lib/docker/volumes/webapp_mysql/_data/isucon_listen80/* > /dev/null 68 | ``` 69 | 70 | マニュアル [docs/README.md](docs/README.md) も参照して下さい。 71 | 72 | ## ベンチマーク実行方法 73 | 74 | ### ローカル 75 | 76 | Go 1.18.x でビルドして下さい。 77 | 78 | ```console 79 | $ cd bench 80 | $ make bench 81 | ``` 82 | 83 | ベンチマークの実行にはデータが必要なため、「起動方法 > ローカル + Docker Compose」の `make dataset` を実行して下さい。 84 | 85 | EC2 AMIにはビルド済みの `bench` コマンドが配置されています。 86 | 87 | ### 実行方法 (ローカル, EC2 共通) 88 | 89 | ```console 90 | $ cd bench 91 | $ ./bench 92 | 93 | (略) 94 | 17:42:27.650368 SCORE: 600 (+610 -10) 95 | 17:42:27.650445 RESULT: score.ScoreTable{"GET /api/playlist/{}":221, "GET /api/playlists":13, "GET /api/popular_playlists":1, "GET /api/popular_playlists (login)":1, "GET /api/recent_playlists":13, "GET /api/recent_playlists (login)":11, "POST /api/login":33, "POST /api/playlist/favorite":175, "POST /api/playlist/{}/add":4, "POST /api/playlist/{}/update":3} 96 | ``` 97 | 98 | 出力される `SCORE: 600` が最終的なスコアです。(+が得点 -がエラーによる減点) 99 | 算出方法についてはマニュアル [docs/README.md](docs/README.md) も参照して下さい。 100 | 101 | 何もオプションを指定しない場合、http://localhost に対してベンチマークを実行します。 102 | 103 | 別のホストに対してベンチマークを実行する場合、`-target-url` を指定して下さい。 104 | 105 | ### オプション 106 | 107 | ``` 108 | Usage of ./bench: 109 | -data-dir string 110 | Data directory (default "data") 111 | -debug 112 | Debug mode 113 | -duration duration 114 | Benchmark duration (default 1m0s) 115 | -exit-error-on-fail 116 | Exit error on fail (default true) 117 | -initialize-request-timeout duration 118 | Initialize request timeout (default 30s) 119 | -prepare-only 120 | Prepare only 121 | -request-timeout duration 122 | Default request timeout (default 15s) 123 | -skip-prepare 124 | Skip prepare 125 | -target-url string 126 | Benchmark target URL (default "http://localhost") 127 | ``` 128 | 129 | 130 | ## LICENSE 131 | 132 | MIT 133 | -------------------------------------------------------------------------------- /bench/cmd/bench/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "os" 7 | "time" 8 | 9 | "github.com/isucon/isucandar" 10 | "github.com/kayac/kayac-isucon-2022/bench" 11 | ) 12 | 13 | const ( 14 | DefaultTargetURL = "http://localhost" 15 | DefaultRequestTimeout = time.Second * 15 16 | DefaultInitializeRequestTimeout = time.Second * 30 17 | DefaultDuration = time.Minute 18 | ) 19 | 20 | func main() { 21 | // ベンチマークオプションの生成 22 | option := bench.Option{} 23 | 24 | // 各フラグとベンチマークオプションのフィールドを紐付ける 25 | flag.StringVar(&option.TargetURL, "target-url", DefaultTargetURL, "Benchmark target URL") 26 | flag.DurationVar(&option.RequestTimeout, "request-timeout", DefaultRequestTimeout, "Default request timeout") 27 | flag.DurationVar(&option.InitializeRequestTimeout, "initialize-request-timeout", DefaultInitializeRequestTimeout, "Initialize request timeout") 28 | flag.DurationVar(&option.Duration, "duration", DefaultDuration, "Benchmark duration") 29 | flag.BoolVar(&option.ExitErrorOnFail, "exit-error-on-fail", true, "Exit error on fail") 30 | flag.BoolVar(&option.PrepareOnly, "prepare-only", false, "Prepare only") 31 | flag.BoolVar(&option.SkipPrepare, "skip-prepare", false, "Skip prepare") 32 | flag.StringVar(&option.DataDir, "data-dir", "data", "Data directory") 33 | flag.BoolVar(&option.Debug, "debug", false, "Debug mode") 34 | 35 | // コマンドライン引数のパースを実行 36 | // この時点で各フィールドに値が設定されます 37 | flag.Parse() 38 | 39 | // 現在の設定を大会運営向けロガーに出力 40 | bench.AdminLogger.Print(option) 41 | bench.Debug = option.Debug 42 | 43 | // シナリオの生成 44 | scenario := &bench.Scenario{ 45 | Option: option, 46 | } 47 | 48 | // ベンチマークの生成 49 | benchmark, err := isucandar.NewBenchmark( 50 | isucandar.WithLoadTimeout(option.Duration), 51 | ) 52 | if err != nil { 53 | bench.ContestantLogger.Println(err) 54 | return 55 | } 56 | 57 | // ベンチマークにシナリオを追加 58 | benchmark.AddScenario(scenario) 59 | 60 | // main で最上位の context.Context を生成 61 | ctx, cancel := context.WithCancel(context.Background()) 62 | defer cancel() 63 | 64 | // ベンチマーク開始 65 | result := benchmark.Start(ctx) 66 | 67 | time.Sleep(time.Second) // 結果が揃うまでちょっと待つ 68 | 69 | // エラーを表示 70 | for i, err := range result.Errors.All() { 71 | // 選手向けにエラーメッセージが表示される 72 | bench.ContestantLogger.Printf("ERROR[%d] %v", i, err) 73 | if i+1 >= bench.MaxErrors { 74 | bench.ContestantLogger.Printf("ERRORは最大%d件まで表示しています", bench.MaxErrors) 75 | break 76 | } 77 | // 大会運営向けにスタックトレース付きエラーメッセージが表示される 78 | // bench.AdminLogger.Printf("%+v", err) 79 | } 80 | 81 | // prepare only の場合はエラーが1件でもあればエラーで終了 82 | if option.PrepareOnly { 83 | if len(result.Errors.All()) > 0 { 84 | os.Exit(1) 85 | } 86 | return 87 | } 88 | 89 | // スコア表示 90 | score, addition, deduction := SumScore(result) 91 | bench.ContestantLogger.Printf("SCORE: %d (+%d %d)", score, addition, -deduction) 92 | bench.ContestantLogger.Printf("RESULT: %#v", result.Score.Breakdown()) 93 | 94 | // 0点以下(fail)ならエラーで終了 95 | if option.ExitErrorOnFail && score <= 0 { 96 | os.Exit(1) 97 | } 98 | } 99 | 100 | func SumScore(result *isucandar.BenchmarkResult) (int64, int64, int64) { 101 | score := result.Score 102 | // 各タグに倍率を設定 103 | score.Set(bench.ScoreGETRoot, 1) 104 | score.Set(bench.ScoreSignup, 1) 105 | score.Set(bench.ScoreLogin, 1) 106 | score.Set(bench.ScoreLogout, 1) 107 | score.Set(bench.ScoreGetPlaylist, 1) 108 | score.Set(bench.ScoreGetPlaylists, 1) 109 | score.Set(bench.ScoreGetRecentPlaylists, 1) 110 | score.Set(bench.ScoreGetPopularPlaylists, 1) 111 | score.Set(bench.ScoreAddPlaylist, 1) 112 | score.Set(bench.ScoreUpdatePlaylist, 1) 113 | score.Set(bench.ScoreFavoritePlaylist, 1) 114 | score.Set(bench.ScoreAdminBan, 1) 115 | // 特別に加点するタグ 116 | score.Set(bench.ScoreGetPopularPlaylistsLogin, 10) 117 | score.Set(bench.ScoreGetRecentPlaylistsLogin, 10) 118 | score.Set(bench.ScoreUpdatePlaylist, 10) 119 | 120 | // 加点分の合算 121 | addition := score.Sum() 122 | 123 | // エラーは1つ10点減点 124 | deduction := len(result.Errors.All()) * 10 125 | 126 | // 合計(0を下回ったら0点にする) 127 | sum := addition - int64(deduction) 128 | if sum < 0 { 129 | sum = 0 130 | } 131 | 132 | return sum, addition, int64(deduction) 133 | } 134 | -------------------------------------------------------------------------------- /bench/scenario_anon.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "time" 8 | 9 | "github.com/isucon/isucandar" 10 | "github.com/isucon/isucandar/worker" 11 | ) 12 | 13 | // ログインしないユーザーのシナリオ 14 | func (s *Scenario) AnonWorker(step *isucandar.BenchmarkStep, p int32) (*worker.Worker, error) { 15 | w, err := worker.NewWorker(func(ctx context.Context, _ int) { 16 | s.AnonScenario(ctx, step) 17 | }, 18 | // 無限回繰り返す 19 | worker.WithInfinityLoop(), 20 | worker.WithUnlimitedParallelism(), 21 | ) 22 | if err != nil { 23 | return nil, err 24 | } 25 | w.SetParallelism(p) 26 | return w, nil 27 | } 28 | 29 | // 匿名User 30 | func (s *Scenario) AnonScenario(ctx context.Context, step *isucandar.BenchmarkStep) error { 31 | report := timeReporter("anonymous") 32 | defer report() 33 | 34 | playlistULIDs := []string{} 35 | ag, _ := s.Option.NewAgent(false) 36 | { 37 | res, err := GetRecentPlaylistsAction(ctx, ag) 38 | v := ValidateResponse("最新プレイリスト一覧(非ログイン)", 39 | step, res, err, 40 | WithStatusCode(200), 41 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 42 | var prevCreatedAt time.Time 43 | if len(r.Playlists) != 100 { 44 | return fmt.Errorf("最新プレイリストが100件ではありません %d", len(r.Playlists)) 45 | } 46 | // ベンチマーカーが把握している最新投稿時刻の10秒前 47 | last := s.LastPublicPlaylistCreatedAt().Add(-10 * time.Second) 48 | // 現在の最新 49 | top := r.Playlists[0].CreatedAt 50 | if !last.IsZero() && last.After(top) { 51 | return fmt.Errorf("最新プレイリスト一覧が古すぎます %s", top) 52 | } 53 | for _, p := range r.Playlists { 54 | // 秒で切りそろえてから比較(サブ秒での逆転は許すため) 55 | createdAt := p.CreatedAt.Truncate(time.Second) 56 | if prevCreatedAt.IsZero() { 57 | prevCreatedAt = createdAt 58 | } else if createdAt.After(prevCreatedAt) { 59 | return fmt.Errorf("最新プレイリスト一覧の作成日時が降順になっていません %s < %s", p.CreatedAt, prevCreatedAt) 60 | } 61 | prevCreatedAt = createdAt 62 | if p.IsPublic == false { 63 | return fmt.Errorf("最新プレイリスト一覧に非公開プレイリストが含まれています") 64 | } 65 | if p.IsFavorited { 66 | return fmt.Errorf("非ログイン状態なのに最新プレイリスト一覧にfavが含まれています") 67 | } 68 | if rand.Intn(100) <= 10 { 69 | playlistULIDs = append(playlistULIDs, p.ULID) 70 | } 71 | } 72 | return nil 73 | }), 74 | ) 75 | if v.IsEmpty() { 76 | step.AddScore(ScoreGetRecentPlaylists) 77 | } else { 78 | return v 79 | } 80 | } 81 | if rand.Int31n(100) <= s.RateGetPopularPlaylists() { // pupularは確率で取る 82 | res, err := GetPopularPlaylistsAction(ctx, ag) 83 | v := ValidateResponse("人気プレイリスト一覧(非ログイン)", 84 | step, res, err, 85 | WithStatusCode(200), 86 | WithSuccessResponse(func(r ResponseAPIGetPopularPlaylists) error { 87 | if len(r.Playlists) > 100 { 88 | return fmt.Errorf("人気プレイリスト一覧が100件を超えています %d", len(r.Playlists)) 89 | } 90 | if len(r.Playlists) == 0 { 91 | return fmt.Errorf("人気プレイリスト一覧が空です") 92 | } 93 | var prevFavs int 94 | for _, p := range r.Playlists { 95 | if prevFavs == 0 { 96 | prevFavs = p.FavoriteCount 97 | } 98 | if p.FavoriteCount == 0 { 99 | return fmt.Errorf("人気プレイリストのfavが0です") 100 | } 101 | if prevFavs < p.FavoriteCount { 102 | return fmt.Errorf("人気プレイリスト一覧のfav数が降順になっていません %d < %d", prevFavs, p.FavoriteCount) 103 | } 104 | prevFavs = p.FavoriteCount 105 | if p.IsPublic == false { 106 | return fmt.Errorf("人気プレイリスト一覧に非公開プレイリストが含まれています") 107 | } 108 | if rand.Intn(100) <= 10 { 109 | playlistULIDs = append(playlistULIDs, p.ULID) 110 | } 111 | } 112 | return nil 113 | }), 114 | ) 115 | if v.IsEmpty() { 116 | step.AddScore(ScoreGetPopularPlaylists) 117 | } else { 118 | return v 119 | } 120 | } 121 | 122 | for _, playlistULID := range playlistULIDs { 123 | // プレイリスト詳細 124 | res, err := GetPlaylistAction(ctx, playlistULID, ag) 125 | v := ValidateResponse("プレイリスト詳細取得(非ログイン)", 126 | step, res, err, 127 | WithStatusCode(200), 128 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 129 | if r.Playlist.ULID != playlistULID { 130 | return fmt.Errorf("プレイリストのULIDが一致しません %s != %s", r.Playlist.ULID, playlistULID) 131 | } 132 | if r.Playlist.IsFavorited { 133 | return fmt.Errorf("非ログイン状態なのにプレイリストがfavされています") 134 | } 135 | return nil 136 | }), 137 | ) 138 | if v.IsEmpty() { 139 | step.AddScore(ScoreGetPlaylist) 140 | } else { 141 | return v 142 | } 143 | } 144 | 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /webapp/golang/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= 5 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 6 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 7 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 8 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 9 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 10 | github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= 11 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 12 | github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= 13 | github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= 14 | github.com/labstack/echo/v4 v4.11.2 h1:T+cTLQxWCDfqDEoydYm5kCobjmHwOwcv4OJAPHilmdE= 15 | github.com/labstack/echo/v4 v4.11.2/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= 16 | github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= 17 | github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= 18 | github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= 19 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 20 | github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 21 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 22 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 23 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 24 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 25 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 26 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 27 | github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= 28 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 29 | github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc= 30 | github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68= 31 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 32 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/srinathgs/mysqlstore v0.0.0-20200417050510-9cbb9420fc4c h1:HT6QRF79dL2Ed6HCrX9RufkxFGo7+NPkgYF1Uzvv/js= 35 | github.com/srinathgs/mysqlstore v0.0.0-20200417050510-9cbb9420fc4c/go.mod h1:kt46Hd+lF0rtpeRgOvYSWYJItOAd73EKkIBZFbX7TXs= 36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 37 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 38 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 39 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 40 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 41 | github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 42 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 43 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 44 | golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= 45 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 46 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 47 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 48 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 54 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 56 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 57 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 58 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 61 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 62 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 63 | -------------------------------------------------------------------------------- /webapp/public/assets/js/playlist_edit.js: -------------------------------------------------------------------------------- 1 | 2 | window.addEventListener('load', () => { 3 | const playlistUlid = document.getElementById('playlist-id').getAttribute('value') 4 | 5 | const editingSongsState = [] 6 | const upSong = (index) => { 7 | const tmp = editingSongsState[index-1] 8 | editingSongsState[index-1] = editingSongsState[index] 9 | editingSongsState[index] = tmp 10 | renderSongList() 11 | } 12 | 13 | const downSong = (index) => { 14 | const tmp = editingSongsState[index+1] 15 | editingSongsState[index+1] = editingSongsState[index] 16 | editingSongsState[index] = tmp 17 | renderSongList() 18 | } 19 | 20 | const deleteSong = (index) => { 21 | editingSongsState.splice(index, 1) 22 | renderSongList() 23 | } 24 | 25 | const candidateSongs = [] 26 | const fetchSongCandidate = async () => { 27 | const res = await fetch('/assets/songs.json') 28 | const songs = await res.json() 29 | 30 | candidateSongs.splice(0) 31 | candidateSongs.push(...songs) 32 | candidateSongs.splice(1000) 33 | 34 | const select = document.getElementById('candidate-songs') 35 | const opts = candidateSongs.map(song => { 36 | return `` 37 | }) 38 | select.innerHTML = opts.join('\n') 39 | } 40 | 41 | const addSongHandler = () => { 42 | // 81曲以上は追加できない 43 | if (editingSongsState.length >= 80) { 44 | window.alert('プレイリストに追加できるのは80曲までです。') 45 | return 46 | } 47 | 48 | const select = document.getElementById('candidate-songs') 49 | const songUlid = select.value 50 | const candidate = candidateSongs.filter(x => x.ulid === songUlid) 51 | if (!candidate.length) { 52 | // 未選択 53 | return 54 | } 55 | 56 | // 既にsong listにいるやつは追加できない 57 | const checker = editingSongsState.filter(x => x.ulid === songUlid) 58 | if (checker.length) { 59 | window.alert('既にプレイリストにある曲です! 追加できません。') 60 | return 61 | } 62 | 63 | const song = candidate[0] 64 | editingSongsState.unshift(song) 65 | renderSongList() 66 | } 67 | 68 | 69 | const renderSongList = () => { 70 | const tbody = document.getElementById('songs-table-body') 71 | const rowHTML = editingSongsState.map((song, index) => { 72 | return ` 73 | 74 | ${index+1} 75 | ${song.title} 76 | ${song.artist} 77 | ${song.album} 78 | ${song.track_number} 79 | 80 | 81 | 82 | 83 | 84 | 85 | ` 86 | }) 87 | 88 | tbody.innerHTML = rowHTML.join('\n') 89 | editingSongsState.forEach((_, index) => { 90 | const upButton = document.getElementById(`up-${index}`) 91 | const downButton = document.getElementById(`down-${index}`) 92 | const delButton = document.getElementById(`delete-${index}`) 93 | upButton.addEventListener('click', () => { upSong(index) }) 94 | downButton.addEventListener('click', () => { downSong(index) }) 95 | delButton.addEventListener('click', () => { deleteSong(index) }) 96 | }) 97 | } 98 | 99 | const getPlaylist = async () => { 100 | 101 | const response = await fetch('/api/playlist/' + playlistUlid) 102 | const json = await response.json() 103 | if (!json.result) { 104 | alert('プレイリスト取得失敗 (他人の非公開プレイリストかも?)') 105 | } 106 | 107 | const playlist = json.playlist 108 | 109 | const namebox = document.getElementById('playlist-name') 110 | namebox.value = playlist.name 111 | 112 | const radioPublic = document.getElementById('public') 113 | const radioPrivate = document.getElementById('private') 114 | 115 | if (playlist.is_public) { 116 | radioPublic.checked = true 117 | } else { 118 | radioPrivate.checked = true 119 | } 120 | 121 | // clear previous state 122 | editingSongsState.splice(0) 123 | editingSongsState.push(...playlist.songs) 124 | 125 | renderSongList() 126 | 127 | return response 128 | } 129 | 130 | const editPlaylist = async (postData) => { 131 | const response = await fetch(`/api/playlist/${playlistUlid}/update`, { 132 | method: 'POST', 133 | headers: { 134 | 'Content-Type': 'application/json', 135 | }, 136 | body: JSON.stringify(postData) 137 | }) 138 | 139 | return response 140 | } 141 | 142 | const saveHandler = async () => { 143 | 144 | // current values 145 | const newName = document.getElementById('playlist-name').value 146 | const newVisibility = document.getElementById('playlist-edit-form').elements['is_public'].value === 'true' 147 | 148 | const postData = { 149 | name: newName, 150 | is_public: newVisibility, 151 | song_ulids: editingSongsState.map(x => x.ulid), 152 | } 153 | 154 | const res = await editPlaylist(postData) 155 | 156 | const event = new Event('refreshRequired') 157 | const elem = document.getElementById('main-app') 158 | elem.dispatchEvent(event) 159 | } 160 | const saveButton = document.getElementById('save') 161 | saveButton.addEventListener('click', saveHandler) 162 | const save2Button = document.getElementById('save2') 163 | save2Button.addEventListener('click', saveHandler) 164 | 165 | const addButton = document.getElementById('add-song') 166 | addButton.addEventListener('click', addSongHandler) 167 | 168 | const main = document.getElementById('main-app') 169 | main.addEventListener('refreshRequired', () => { 170 | getPlaylist() 171 | }) 172 | 173 | fetchSongCandidate() 174 | getPlaylist() 175 | }) 176 | -------------------------------------------------------------------------------- /webapp/public/assets/js/lib.js: -------------------------------------------------------------------------------- 1 | const favHandler = async (id, currentValue) => { 2 | const favorite = async (id, newState) => { 3 | const response = await fetch(`/api/playlist/${id}/favorite`, { 4 | method: 'POST', 5 | headers: { 6 | 'Content-Type': 'application/json', 7 | }, 8 | body: JSON.stringify({ 9 | is_favorited: newState, 10 | }) 11 | }) 12 | const json = await response.json() 13 | if (!json.result) { 14 | alert('ラブ失敗 (ログインしてますか?)') 15 | } 16 | 17 | return response 18 | } 19 | 20 | const newState = !currentValue 21 | 22 | await favorite(id, newState) 23 | 24 | const event = new Event('refreshRequired') 25 | const elem = document.getElementById('main-app') 26 | elem.dispatchEvent(event) 27 | } 28 | 29 | const deleteHandler = async (id) => { 30 | const deletePlaylist = async (id) => { 31 | const response = await fetch(`/api/playlist/${id}/delete`, { 32 | method: 'POST', 33 | }) 34 | const json = await response.json() 35 | if (!json.result) { 36 | alert('delete失敗 (ログインしてますか? 自分のですか?)') 37 | } 38 | return response 39 | } 40 | 41 | if (window.confirm('このプレイリストを削除していいですか?')) { 42 | await deletePlaylist(id) 43 | 44 | const event = new Event('refreshRequired') 45 | const elem = document.getElementById('main-app') 46 | elem.dispatchEvent(event) 47 | } 48 | } 49 | 50 | function playlistToHTML(playlist, owner) { 51 | const createdAt = new Date(Date.parse(playlist.created_at)).toLocaleString() 52 | const updatedAt = new Date(Date.parse(playlist.updated_at)).toLocaleString() 53 | const favstat = playlist.is_favorited ? 'fav' : '' 54 | const visibility = owner ? ( 55 | playlist.is_public ? ` 56 | 57 | 58 | ` : ` 59 | 60 | 61 | 62 | ` 63 | ) : '' 64 | const editButton = owner ? ` 65 | 66 | 67 | 68 | ` : '' 69 | 70 | const deleteButton = owner ? ` 71 | 72 | 73 | 74 | ` : '' 75 | 76 | return ` 77 |
78 |
79 |
80 | ${visibility} 81 | ${editButton} 82 | ${deleteButton} 83 | 84 | 85 | 86 |
87 |

${playlist.name}

88 |
89 |
90 |
91 |
92 | ${playlist.user_display_name} 93 |
94 |
95 | ${playlist.song_count}曲 96 |
97 |
98 | ${playlist.favorite_count}コ 99 |
100 |
101 | 作成: ${createdAt} | 最終更新: ${updatedAt} 102 |
103 |
104 |
` 105 | } -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # 社内 ISUCON レギュレーション & 当日マニュアル 2 | 3 | ## スケジュール 4 | 5 | - 9:30 競技説明開始 6 | - 10:00 競技開始 7 | - 17:00 競技終了 8 | - 18:00 結果発表・講評 9 | 10 | ## サーバ事項 11 | 12 | 参加者は主催者が用意したAmazon Web Services(以下AWS)のEC2インスタンスを1台利用する。 13 | 14 | 参加登録時に申請したGitHubアカウントに登録されている公開鍵 (`github.com/{username}.keys` で配布されているもの) を用いて、`isucon` ユーザーで ssh 接続が可能。 15 | 16 | ## ソフトウェア事項 17 | 18 | コンテストにあたり、参加者は与えられたソフトウェア、もしくは自分で競技時間内に実装したソフトウェアを用いる。 19 | 20 | 高速化対象のソフトウェアとして、主催者からTypeScriptとGoによるWebアプリケーションが与えられる。独自で実装したものを用いてもよい。 21 | 22 | 競技における高速化対象のアプリケーションとして与えられたアプリケーションから、以下の機能は変更しないこと。 23 | 24 | * アクセス先のURI(ポート、およびHTTPリクエストパス) 25 | * ただしサーバー側で生成する部分(ID)は文字種([0-9] や [0-9a-zA-Z_] など)を変えない範囲で自由に生成しても良い 26 | * APIレスポンス(JSON)の構造 27 | * JavaScript/CSSファイルの内容 28 | * 画像および動画等のメディアファイルの内容 29 | * パスワードハッシュアルゴリズム、およびそのラウンド数 30 | 31 | 各サーバにおけるソフトウェアの入れ替え、設定の変更、アプリケーションコードの変更および入れ替えなどは一切禁止しない。起動したインスタンス以外の外部リソースを利用する行為(他のインスタンスに処理を委譲するなど)は禁止する。 32 | ただしモニタリングやテスト、開発などにおいては、PCや外部のサーバーを利用しても構わない。 33 | 34 | 許可される事項には、例として以下のような作業が含まれる。 35 | 36 | * DBスキーマの変更やインデックスの作成・削除 37 | * キャッシュ機構の追加、jobqueue機構の追加による遅延書き込み 38 | * 利用するミドルウェアの変更 39 | * 他の言語による再実装 40 | 41 | ただし以下の事項に留意すること。 42 | 43 | * コンテスト進行用のメンテナンスコマンドが正常に動作するよう互換性を保つこと 44 | * EC2上で動作している ssm-agent を停止しないこと 45 | * サーバ再起動後にすべてのアプリケーションコードが正常動作する状態を維持すること 46 | * ベンチマーク実行時にアプリケーションに書き込まれたデータは再起動後にも取得できること 47 | 48 | ## 禁止事項 49 | 50 | 以下の事項は特別に禁止する。 51 | 52 | * 他のチームへの妨害と主催者がみなす全ての行為 53 | 54 | ## 採点 55 | 56 | 採点は採点条件(後述)をクリアした参加者の間で、性能値(後述)の高さを競うものとする。 57 | 58 | 採点条件として、以下の各チェックの検査を通過するものとする。 59 | 60 | * 負荷走行中、ログイン中のユーザーがPOSTした内容は、POSTへのHTTPレスポンスが返却された後に同一ユーザーがリクエストしたレスポンス内容に即座に反映されていること 61 | * 例外がある場合は [API仕様書](API.md) に記述されている 62 | * ログイン中のユーザーに対する API(URLのパス `/api/*`)のレスポンスについては、HTTPヘッダで `Cache-Control: private` を付与すること 63 | * APIレスポンスのJSON構造が変化していないこと 64 | * 各APIリクエストは15秒以内にレスポンスを返却する必要がある 65 | * 例外として、ベンチマーカーが開始時に1回だけ送信する初期化リクエスト `POST /initialize` については30秒まで許容する 66 | * ブラウザから対象アプリケーションにアクセスした結果、ページ上の表示および各種動作が正常であること 67 | 68 | 性能値として、以下の指標を用いる。 69 | 70 | * 性能値を計測するための計測ツールの実行時間は1分間とする 71 | * ベンチマーカーが開始時に行う「整合性チェック」が完了するまでの時間は含まない 72 | * 計測時間内のHTTPリクエスト成功数をベースとする 73 | * 「整合性チェック」のリクエスト数はスコアとして算入しない 74 | * リクエストの種類毎に配点を変更する 75 | * ログイン中のユーザーによるプレイリストの更新 (`POST /api/playlist/{*}/update`) は10点 76 | * ログイン中のユーザーによる最新プレイリスト一覧取得(`GET /api/recent_playlists`)、人気のプレイリスト一覧取得(`GET /api/popular_playlists`) は10点 77 | * それ以外のリクエストは全て1点 78 | * エラーの数により減点する 79 | * リクエストタイムアウトが発生した場合、レスポンスがアプリケーションとして期待される挙動を示さなかった場合にはエラーとする 80 | * エラーが1回発生するごとに-10点 81 | * 負荷走行中にエラーが30回以上発生した場合は、その時点で負荷走行を打ち切ることがある 82 | 83 | ## アプリケーションについて 84 | 85 | このアプリケーションは **Listen80** というプレイリスト共有サービスである。 86 | 87 | ![](listen80.png) 88 | 89 | 次の機能が提供されている。 90 | 91 | - ログインしない状態で、トップページが閲覧できる 92 | - 人気のプレイリスト100件と最新プレイリスト100件が閲覧できる 93 | - プレイリストの詳細が閲覧できる 94 | - サインアップすることで、新規ユーザーを登録できる 95 | - ログインすると、マイページが表示される 96 | - 自分が作成したプレイリスト最新最大100件と、自分がラブ(♡)を付与したプレイリスト最新最大100件が閲覧できる 97 | - マイページからはプレイリストの作成と編集ができる 98 | - プレイリストは非公開(自分以外には閲覧できない)状態で作成される 99 | - 編集して曲を追加したり、公開にしたりできる 100 | - 管理APIがあり、ユーザーをBANできる 101 | - BANされたユーザーはAPIが利用できなくなる 102 | - BANされたユーザーが作成したプレイリストは他のユーザーからは見えなくなる 103 | - 管理APIではユーザーのBANを解除できる 104 | 105 | 詳細な API 仕様については [API仕様書](API.md) に記述されている。 106 | 107 | 仕様と初期実装に齟齬がある場合、ベンチマーカーがエラーを検出しない限りはどちらに準拠しても構わない。 108 | 109 | テスト用のユーザー 110 | 111 | - 一般ユーザー アカウント名 `isucon` パスワード `isuconpass` 112 | - 管理者 アカウント名 `adminuser` パスワード `adminuser` 113 | 114 | ### アプリケーション動作環境 115 | 116 | 競技用EC2インスタンスでは、docker compose によってアプリケーションとミドルウェア(nginx, MySQL)が起動している。 117 | 118 | `/home/isucon/webapp` で次のコマンドを実行することで、停止、コンテナのビルド、起動などが行える。 119 | 120 | ```console 121 | $ docker-compose down # 停止 122 | $ docker-compose up --build # コンテナビルドをしてから起動(foreground) 123 | $ docker-compose up --build -d # コンテナビルドをしてから起動(daemon) 124 | $ docker-compose logs -f # コンテナがstdout/stderrに出力したログを閲覧 125 | ``` 126 | 127 | #### アプリケーション 128 | 129 | アプリケーションの実装は `webapp/{node,golang}` 以下に配置されている。 130 | 131 | 初期状態ではnode(TypeScript)実装が起動している。Go実装に切り替えたい場合は `webapp/docker-compose.yml` の `build:` を変更して `docker-compose` でビルドを行うこと。 132 | 133 | ```yaml 134 | app: 135 | cpus: 1 136 | mem_limit: 1g 137 | # Go実装の場合は golang/ node実装の場合は node/ 138 | build: node/ 139 | environment: 140 | ISUCON_DB_HOST: mysql 141 | ISUCON_DB_PORT: 3306 142 | ISUCON_DB_USER: isucon 143 | ISUCON_DB_PASSWORD: isucon 144 | ISUCON_DB_NAME: isucon_listen80 145 | links: 146 | - mysql 147 | volumes: 148 | - ./public:/home/isucon/webapp/public 149 | - gopkg:/usr/local/go/pkg 150 | init: true 151 | restart: always 152 | ``` 153 | 154 | 155 | #### nginx 156 | 157 | docker-compose で起動している nginx は起動時にEC2上の `webapp/nginx/conf.d/default.conf` を読み込む。このファイルを編集することでコンテナをビルドしなくても設定が変更できる。 158 | 159 | ```yaml 160 | nginx: 161 | image: nginx:1.20 162 | volumes: 163 | - ./nginx/conf.d:/etc/nginx/conf.d 164 | - ./public:/public 165 | ports: 166 | - "80:80" 167 | links: 168 | - app 169 | restart: always 170 | ``` 171 | 172 | #### MySQL 173 | 174 | docker-compose で起動している MySQL は、起動時にEC2上の `webapp/mysql/my.cnf` を読み込む。このファイルを編集することでコンテナをビルドしなくても設定が変更できる。 175 | 176 | ```yaml 177 | mysql: 178 | cpus: 1 179 | mem_limit: 1g 180 | image: mysql/mysql-server:8.0.28 181 | environment: 182 | - "MYSQL_ROOT_HOST=%" 183 | - "MYSQL_ROOT_PASSWORD=root" 184 | volumes: 185 | - ../sql:/docker-entrypoint-initdb.d 186 | - mysql:/var/lib/mysql 187 | - ./mysql/my.cnf:/etc/my.cnf 188 | - ./mysql/logs:/var/log/mysql 189 | ports: 190 | - 13306:3306 191 | restart: always 192 | ``` 193 | 194 | EC2の TCP 13306 ポートを開いているため、EC2ホスト側から以下のコマンドで接続できる。 195 | 196 | ```console 197 | $ mysql -uroot -proot --host 127.0.0.1 --port 13306 isucon_listen80 198 | ``` 199 | 200 | ### 初期状態へのデータリセット方法 201 | 202 | `/home/isucon/sql` 以下に初期化用のSQLファイルがあるので、リセットしたい場合は次のようにしてimportできる。 203 | 204 | ```console 205 | $ cd /home/isucon 206 | $ mysql -uroot -proot --host 127.0.0.1 --port 13306 isucon_listen80 < sql/90_isucon_listen80_dump.sql 207 | ``` 208 | 209 | ``` 210 | sql 211 | ├── 00_database_user.sql # ユーザー定義 212 | ├── 50_listen80_schema.sql # アプリケーション用のスキーマ定義 213 | └── 90_isucon_listen80_dump.sql # 初期データ 214 | ``` 215 | -------------------------------------------------------------------------------- /bench/action.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "sync" 9 | 10 | "github.com/isucon/isucandar/agent" 11 | ) 12 | 13 | var globalPool = sync.Pool{ 14 | New: func() interface{} { 15 | return &bytes.Buffer{} 16 | }, 17 | } 18 | 19 | func GetInitializeAction(ctx context.Context, ag *agent.Agent) (*http.Response, error) { 20 | // リクエストを生成 21 | b, reset, err := newRequestBody(struct{}{}) 22 | if err != nil { 23 | return nil, err 24 | } 25 | defer reset() 26 | req, err := ag.POST("/initialize", b) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | // リクエストを実行 32 | return ag.Do(ctx, req) 33 | } 34 | 35 | func GetRootAction(ctx context.Context, ag *agent.Agent) (*http.Response, error) { 36 | // リクエストを生成 37 | req, err := ag.GET("/") 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | // リクエストを実行 43 | return ag.Do(ctx, req) 44 | } 45 | 46 | func newRequestBody(obj any) (*bytes.Buffer, func(), error) { 47 | b := globalPool.Get().(*bytes.Buffer) 48 | reset := func() { 49 | b.Reset() 50 | globalPool.Put(b) 51 | } 52 | if err := json.NewEncoder(b).Encode(obj); err != nil { 53 | reset() 54 | return nil, nil, err 55 | } 56 | return b, reset, nil 57 | } 58 | 59 | func SignupAction(ctx context.Context, ag *agent.Agent) (*User, *http.Response, error) { 60 | // リクエストを生成 61 | account, password, name := GenerateUserAccount(), RandomString(32), DisplayName() 62 | b, reset, err := newRequestBody(struct { 63 | UserAccount string `json:"user_account"` 64 | Password string `json:"password"` 65 | DisplayName string `json:"display_name"` 66 | }{ 67 | UserAccount: account, 68 | Password: password, 69 | DisplayName: name, 70 | }) 71 | if err != nil { 72 | return nil, nil, err 73 | } 74 | defer reset() 75 | 76 | req, err := ag.POST("/api/signup", b) 77 | if err != nil { 78 | return nil, nil, err 79 | } 80 | req.Header.Set("Content-Type", "application/json") 81 | // リクエストを実行 82 | if res, err := ag.Do(ctx, req); err != nil { 83 | return nil, res, err 84 | } else { 85 | return &User{ 86 | Account: account, 87 | Password: password, 88 | DisplayName: name, 89 | }, res, nil 90 | } 91 | } 92 | 93 | func LoginAction(ctx context.Context, user *User, ag *agent.Agent) (*http.Response, error) { 94 | report := timeReporter("login action") 95 | defer report() 96 | // リクエストを生成 97 | b, reset, err := newRequestBody(struct { 98 | UserAccount string `json:"user_account"` 99 | Password string `json:"password"` 100 | }{ 101 | UserAccount: user.Account, 102 | Password: user.Password, 103 | }) 104 | if err != nil { 105 | return nil, err 106 | } 107 | defer reset() 108 | 109 | req, err := ag.POST("/api/login", b) 110 | if err != nil { 111 | return nil, err 112 | } 113 | req.Header.Set("Content-Type", "application/json") 114 | // リクエストを実行 115 | return ag.Do(ctx, req) 116 | } 117 | 118 | func LogoutAction(ctx context.Context, user *User, ag *agent.Agent) (*http.Response, error) { 119 | // リクエストを生成 120 | b, reset, err := newRequestBody(struct{}{}) 121 | if err != nil { 122 | return nil, err 123 | } 124 | defer reset() 125 | 126 | req, err := ag.POST("/api/logout", b) 127 | if err != nil { 128 | return nil, err 129 | } 130 | req.Header.Set("Content-Type", "application/json") 131 | // リクエストを実行 132 | return ag.Do(ctx, req) 133 | } 134 | 135 | func GetPlaylistsAction(ctx context.Context, ag *agent.Agent) (*http.Response, error) { 136 | req, err := ag.GET("/api/playlists") 137 | if err != nil { 138 | return nil, err 139 | } 140 | // リクエストを実行 141 | return ag.Do(ctx, req) 142 | } 143 | 144 | func GetRecentPlaylistsAction(ctx context.Context, ag *agent.Agent) (*http.Response, error) { 145 | req, err := ag.GET("/api/recent_playlists") 146 | if err != nil { 147 | return nil, err 148 | } 149 | // リクエストを実行 150 | return ag.Do(ctx, req) 151 | } 152 | 153 | func GetPopularPlaylistsAction(ctx context.Context, ag *agent.Agent) (*http.Response, error) { 154 | req, err := ag.GET("/api/popular_playlists") 155 | if err != nil { 156 | return nil, err 157 | } 158 | // リクエストを実行 159 | return ag.Do(ctx, req) 160 | } 161 | 162 | func GetPlaylistAction(ctx context.Context, id string, ag *agent.Agent) (*http.Response, error) { 163 | req, err := ag.GET("/api/playlist/" + id) 164 | if err != nil { 165 | return nil, err 166 | } 167 | // リクエストを実行 168 | return ag.Do(ctx, req) 169 | } 170 | 171 | func AddPlayistAction(ctx context.Context, name string, ag *agent.Agent) (*http.Response, error) { 172 | // リクエストを生成 173 | b, reset, err := newRequestBody(struct { 174 | Name string `json:"name"` 175 | }{ 176 | Name: name, 177 | }) 178 | if err != nil { 179 | return nil, err 180 | } 181 | defer reset() 182 | 183 | req, err := ag.POST("/api/playlist/add", b) 184 | if err != nil { 185 | return nil, err 186 | } 187 | req.Header.Set("Content-Type", "application/json") 188 | // リクエストを実行 189 | return ag.Do(ctx, req) 190 | } 191 | 192 | func UpdatePlayistAction(ctx context.Context, p *Playlist, ag *agent.Agent) (*http.Response, error) { 193 | // リクエストを生成 194 | b, reset, err := newRequestBody(struct { 195 | Name string `json:"name"` 196 | SongULIDs []string `json:"song_ulids"` 197 | IsPublic bool `json:"is_public"` 198 | }{ 199 | Name: p.Name, 200 | SongULIDs: p.Songs.ULIDs(), 201 | IsPublic: p.IsPublic, 202 | }) 203 | if err != nil { 204 | return nil, err 205 | } 206 | defer reset() 207 | 208 | req, err := ag.POST("/api/playlist/"+p.ULID+"/update", b) 209 | if err != nil { 210 | return nil, err 211 | } 212 | req.Header.Set("Content-Type", "application/json") 213 | // リクエストを実行 214 | return ag.Do(ctx, req) 215 | } 216 | 217 | func FavoritePlaylistAction(ctx context.Context, p *Playlist, ag *agent.Agent) (*http.Response, error) { 218 | // リクエストを生成 219 | b, reset, err := newRequestBody(struct { 220 | IsFavorited bool `json:"is_favorited"` 221 | }{ 222 | IsFavorited: p.IsFavorited, 223 | }) 224 | if err != nil { 225 | return nil, err 226 | } 227 | defer reset() 228 | 229 | req, err := ag.POST("/api/playlist/"+p.ULID+"/favorite", b) 230 | if err != nil { 231 | return nil, err 232 | } 233 | req.Header.Set("Content-Type", "application/json") 234 | // リクエストを実行 235 | return ag.Do(ctx, req) 236 | } 237 | 238 | func DeletePlaylistAction(ctx context.Context, p *Playlist, ag *agent.Agent) (*http.Response, error) { 239 | req, err := ag.POST("/api/playlist/"+p.ULID+"/delete", nil) 240 | if err != nil { 241 | return nil, err 242 | } 243 | req.Header.Set("Content-Type", "application/json") 244 | // リクエストを実行 245 | return ag.Do(ctx, req) 246 | } 247 | 248 | func AdminBanAction(ctx context.Context, user *User, isBan bool, ag *agent.Agent) (*http.Response, error) { 249 | b, reset, err := newRequestBody(struct { 250 | IsBan bool `json:"is_ban"` 251 | UserAccount string `json:"user_account"` 252 | }{ 253 | IsBan: isBan, 254 | UserAccount: user.Account, 255 | }) 256 | if err != nil { 257 | return nil, err 258 | } 259 | defer reset() 260 | 261 | req, err := ag.POST("/api/admin/user/ban", b) 262 | if err != nil { 263 | return nil, err 264 | } 265 | req.Header.Set("Content-Type", "application/json") 266 | // リクエストを実行 267 | return ag.Do(ctx, req) 268 | } 269 | -------------------------------------------------------------------------------- /bench/rand.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | crand "crypto/rand" 5 | "encoding/binary" 6 | "math/rand" 7 | "time" 8 | 9 | "github.com/oklog/ulid/v2" 10 | ) 11 | 12 | var entropy *ulid.MonotonicEntropy 13 | 14 | func init() { 15 | var s int64 16 | if err := binary.Read(crand.Reader, binary.LittleEndian, &s); err != nil { 17 | // crypto/rand からReadできなかった場合の代替手段 18 | s = time.Now().UnixNano() 19 | } 20 | rand.Seed(s) 21 | 22 | entropy = ulid.Monotonic(rand.New(rand.NewSource(s)), 0) 23 | } 24 | 25 | func newULID() ulid.ULID { 26 | return ulid.MustNew(ulid.Timestamp(time.Now()), entropy) 27 | } 28 | 29 | func RandomString(n int) string { 30 | var letter = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 31 | 32 | b := make([]rune, n) 33 | for i := range b { 34 | b[i] = letter[rand.Intn(len(letter))] 35 | } 36 | return string(b) 37 | } 38 | 39 | // https://github.com/isucon/isucon10-final/blob/b2d2291d9938fefc879a6c8a2f65bd6335f2b873/benchmarker/random/team_name.go 40 | 41 | func DisplayName() string { 42 | return generatePrefix1() + generatePrefix2() + generateMiddle() + generateSuffix() 43 | } 44 | 45 | func generatePrefix1() string { 46 | return "" 47 | } 48 | 49 | var prefix2Data = []string{ 50 | "スーパー", "ハイパー", "ウルトラ", "ブリリアント", "冷やし", "大人の", "たのしい", "いつもの", "野生の", "すごい", "かなり", "ちょっと", "すこし", "やっぱり", "スペシャル", 51 | "プレミアム", "必勝の", "最速の", "勝利の", "あの", "いつかは", "インターナショナル", "ドメスティック", "ベスト", "グローバル", "世界の", "ああ", "ザ・", "THE", "RE:", 52 | "新しい", "ニュー", "いざ", "さあ", "だいたい", "立派な", "あわよくば", "すべからく", "おしなべて", "はやくも", "がんばれ", "さよなら", "ようこそ", "こんにちは", "朝の", 53 | "昼の", "夜の", "終末の", "異世界", "アイドル", "ハッピー", "ラッキー", "パッション", "マネージド", "エンベデッド", "モディファイド", "エフェクティブ", "エラスティック", 54 | "ウェブスケール", "中華風", "洋風", "地中海風", "和風", "中世", "令和", "昭和", "レガシー", "古代", "伝説の", "立体的", "平面的", "海の", "山の", "川の", "日本の", 55 | "電気仕掛けの", "手仕込み", "ロイヤル", "アポカリプティック", "破壊的", "動的", "静的", "センシティブ", "リアクティブ", "自動", "手動", "大きな", "小さな", "ふつうの", 56 | "サルでもわかる", "たべられる", "たべられない", "ゆっくり", "天から", "空から", "魁!", "仮面", "よしなに", "激辛", "甘口", "やわらか", "アルティメット", "羽つき", "MC", 57 | "DJ", "Dr.", "金で", "絶対", "システム", "東", "西", "北", "南", "滑り込み", "椅子", "アラフォーだって", "いい感じに", "スライディング", "本番", "ステージング", "rm", 58 | "-rf", "100日後に", "限界集落", "いっけな〜い", "おしゃれ", "リッチ", "実質", "実用段階", "即", "出会って5秒で", "まじめに", "2段飛ばし", "隠れ家的", "ビストロ", 59 | "レストラン", "街中華", "ハリガネ", "箱出し", "かため", "ふつう", "やわらかめ", "やわやわ", "大盛り", "高速", "リサイクル", "徹底的", "元祖", "本家", "非同期", 60 | "魔法のような", "ファッション", "カジュアル", "エンジョイ", "第3", "第5", "生焼け", "あなたと", "私と", "俺の", "私の", "新卒", "こだわり", "実は", "できたて", "白", 61 | "赤", "青", "黒", "肉の", "焼き", "あんかけ", "麻婆", "台湾", "信頼の", "新", "真", "マジック", "奇跡の", "多段", "マルチ", "並列", "並行", "顔が良い", "夏の", 62 | "春の", "秋の", "冬の", "ステルス", "待って", "埼玉の", "千葉の", "栃木の", "群馬の", "買える", "会える", "影の", "闇の", "光の", "聖なる", "炎の", "よくわかる", 63 | "子供用", "大人用", "のびる", "よく効く", "当たる", "勝てる", "魔の", "スキニー", "丸腰", "宇宙の", "事実上", "リアル", "バーチャル", "肉体", "筋肉", "ロック", 64 | "メタル", "ヤバ", "多重", "全二重", "半二重", "I/O", "XSUCON", "最高", "マスクド", "天然", "無農薬", "偽", "業務中に", "比較的", "ノンアル", "無限", "一生", 65 | "100万回", "無料", "パッと見", "究極の", "至高の", "スピリチュアル", "特殊", "あつまれ", "三度の飯より", "メイン", "サブ", "大統一", "週末", "量子", "放課後", 66 | "朝から晩まで", "朝まで", "今夜は", "自称", "天才", 67 | } 68 | var middleData = []string{ 69 | "いちご", "桃", "梨", "マスカット", "ぶどう", "みかん", "マンゴー", "スイカ", "パイナップル", "メロン", "りんご", "さくらんぼ", "バナナ", "キウイ", "柿", "ライチ", 70 | "グレープフルーツ", "洋梨", "ざくろ", "ラズベリー", "ドラゴンフルーツ", "デコポン", "すもも", "アメリカンチェリー", "レモン", "マンゴスチン", "いよかん", "パパイヤ", 71 | "オレンジ", "びわ", "パッションフルーツ", "ブルーベリー", "いちじく", "夏みかん", "ゆず", "ミニトマト", "晩白柚", "シークワーサー", "ランブータン", "ポンカン", 72 | "ココナッツ", "スターフルーツ", "餃子", "カレー", "ラーメン", "寿司", "ビーフ", "ポーク", "ミルク", "ミックス", "エビチリ", "チャーシュー", "バンバンジー", 73 | "ハンバーガー", "トンポーロー", "ホイコーロー", "ピザ", "春巻き", "エビ", "カニ", "アワビ", "フカヒレ", "豆腐", "トーフ", "カルボナーラ", "ペペロンチーノ", 74 | "ミネストローネ", "リゾット", "パエリア", "フィッシュ", "ツナ", "みたらし", "カスタード", "じゃがいも", "キャベツ", "大根", "たまねぎ", "白菜", "トマト", "にんじん", 75 | "レタス", "きゅうり", "ネギ", "もやし", "なす", "ほうれん草", "かぼちゃ", "里芋", "ピーマン", "ブロッコリー", "ごぼう", "レンコン", "枝豆", "横浜", "恵比寿", 76 | "吉祥寺", "大宮", "目黒", "品川", "新宿", "池袋", "中目黒", "浦和", "渋谷", "東京", "鎌倉", "中野", "表参道", "自由が丘", "赤羽", "二子玉川", "さいたま新都心", 77 | "武蔵小杉", "船橋", "北千住", "立川", "たまプラーザ", "柏", "川崎", "海老名", "荻窪", "三軒茶屋", "藤沢", "千葉", "桜木町", "三鷹", "上野", "津田沼", "秋葉原", 78 | "舞浜", "みなとみらい", "つくば", "青山一丁目", "下北沢", "広尾", "川越", "和光市", "川口", "浦安", "目白", "代々木", "町田", "流山おおたかの森", "ビール", 79 | "ワイン", "酎ハイ", "リキュール", "清酒", "焼酎", "ブランデー", "ウィスキー", "エール", "ラガー", "ピルスナー", "IPA", "コク", "キレ", "酸味", "苦み", "甘み", 80 | "パブ", "タゴ", "モリス", "ミラ", "クイ", "ロージー", "リリー", "ソラ", "ハー", "くしい", "ドイツビール", "ペールエール", "ベルギービール", "カメラ", "コーヒー", 81 | "ルービックキューブ", "バ美肉", "おじさん", "おばさん", "鏡", "家", "運営", "運用", "ソフトウェア", "ハードウェア", "FPGA", "gRPC", "データベース", 82 | "ネットワーク", "HTTP/2", "たんぽぽ", "薔薇", "チューリップ", "サッカー", "ボルダリング", "相撲", "タブレット", "センシティブ", "スマホ", "ダッシュ", "マスク", 83 | "チーズ", "ペットボトル", "自作キーボード", "イス", "土下座", "うどん", "そば", "きしめん", "スパゲッティ", "細麺", "太麺", "始末書", "サービス", "マーケティング", 84 | "魔剤", "アロエ", "ライブ", "ハック", "チェンソー", "パワー", "おにぎり", "筋肉", "NAT", "解体", "ロードアベレージ", "パフォーマンス", "インスタンス", 85 | "サーバレス", "ノーコード", "プロセス", "C10K", "サーバ", "クライアント", "キャッシュ", "浸透", "年収", "高学歴", "無職", "エラー", "アラート", "オンコール", 86 | "インデックス", "Cache-Control", "Protocol", "Buffers", "プロファイリング", "ベンチマーク", "fail", "blazingly", "fast", "CPU", "メモリ", "GPU", 87 | "GPGPU", "JSON色付け", "コンテナ", "モンゴ", "デブオプス", "マッスル", "腹筋", "上腕二頭筋", "大臀筋", "課金", "無課金", 88 | } 89 | var suffixData = []string{ 90 | "部", "会", "劇場", "団", "太郎", "工場", "委員会", "倶楽部", "クラブ", "計画", "屋", "家", "の楽園", "の世界", "ドロップ", "ボックス", "亭", "の晩餐", "乃月", 91 | "!", "ごはん", "めし", "工房", "姉妹", "ブラザーズ", "シスターズ", "ファミリー", "家族", "中毒", "スターズ", "のーと", "食べたい", "依存症", "絵本", "茶", 92 | "人生", "キック", "パンチ", "フォビア", "株式会社", "(株)", "店", "ヶ丘", "堂", "の日", "館", "党", "本舗", "煮", "組", "理論", "式", "夜会", "組合", 93 | "文庫", "荘", "の会", "商事", "週間", "協奏曲", "鎮魂歌", "序曲", "の森", "役場", "乙女", "動物園", "研究所", "風", "屋台", "小屋", "日記", "連合", 94 | "シンポジウム", "野郎", "神社", "の部屋", "ゲート", "コミック", "派", "サンドイッチ", "人", "本部", "支部", "帝国", "共和国", "合衆国", "でごめん", "軒", "道場", 95 | "教室", "出張所", "派出所", "のお茶会", "の音", "興信所", "事務所", "探偵", "図鑑", "戦線", "同盟", "食堂", "製菓", "まんじゅう", "図書館", "の中", "の缶詰", 96 | "爆弾", "の惨劇", "同好会", "ドーナツ", "都市", "基地", "メランコリー", "マニア", "バトル", "オンライン", "オフライン", "プリン", "賛歌", "天国", "地獄", 97 | "の5秒前", "のプロ", "効果", "がいる", "同位体", "通信社", "の彼方", "酒店", "MIX", "整骨院", "書房", "百貨店", "雑貨店", "牧場", "症候群", "醸造", "製作所", 98 | "企画", "チャンプルー", "のおうち", "で解決", "クラッシャー", "は卒業しました", ".dev", "に全振り", "テクノロジーズ", "山", "穏健派", "過激派", "はバランスいい", 99 | "〜♪", "係", "丼", "は命より重い", "激アツ", "ドリブン", "もどき", "から始めるXSUCON", "IS", "BETTER", "THAN", "PERFECT", "乗り過ごし", "ください", 100 | "こわれました", "イーツ", "乃風", "レンジャー", "ネイティブ", "ヒーローズ", "駆動開発", "テーマパーク", "がヤバい", ".go", ".rb", ".py", ".js", ".java", 101 | ".rs", ".pl", ".php", "エンジニア", "はともだち", "でごめんなさい", "土下座", "で再起動", "騎士団", "卒", "入門", "屋さん", "の上の刺身", "ラボ", "ビーチ", 102 | "海岸", "田中", "方式", "は飲み物です", "カレー", "ラーメン", "ロワイヤル", "物語", "学園", "大学", "大好き", "マン", "完全理解", "システム", "王国", 103 | "を信じろ", "_all", "段階", "不足", "ず", "ズ", "勢", "5期生", "タウン", "シティ", "高校", "します", "だねえ", "だもの", "しかねえ", "厳しい", "大全", 104 | "だらけ", "と俺", "なら負けない", "しか勝たん", "してもろて", "90%オフ", "氏", "以外", "ゼロ", "の如し", "ガチ勢", "一択", "できた", "は諦めた", "する方法", 105 | "ようちえん", "無双", "づくし", "単推し", "まみれ", "のオタク", "ざんまい", "オペレーションズ", "(仮)", "君", "さん", "ちゃん", 106 | } 107 | 108 | func generatePrefix2() string { 109 | if rand.Intn(2) < 1 { 110 | return prefix2Data[rand.Intn(len(prefix2Data))] 111 | } else { 112 | return "" 113 | } 114 | } 115 | 116 | func generateMiddle() string { 117 | return middleData[rand.Intn(len(middleData))] 118 | } 119 | 120 | func generateSuffix() string { 121 | return suffixData[rand.Intn(len(suffixData))] 122 | } 123 | -------------------------------------------------------------------------------- /bench/validation.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/isucon/isucandar" 11 | "github.com/isucon/isucandar/failure" 12 | ) 13 | 14 | // failure.NewError で用いるエラーコード定義 15 | const ( 16 | ErrInvalidStatusCode failure.StringCode = "status-code" 17 | ErrInvalidCacheControl failure.StringCode = "cache-control" 18 | ErrInvalidJSON failure.StringCode = "broken-json" 19 | ErrInvalidPath failure.StringCode = "path" 20 | ErrFailed failure.StringCode = "failed" 21 | ErrValidation failure.StringCode = "validation" 22 | ) 23 | 24 | type ValidationError struct { 25 | Errors []error 26 | Title string 27 | } 28 | 29 | // error インターフェースを満たす Error メソッド 30 | func (v ValidationError) Error() string { 31 | messages := []string{} 32 | 33 | for _, err := range v.Errors { 34 | if err != nil { 35 | messages = append(messages, fmt.Sprintf("%v", err)) 36 | } 37 | } 38 | 39 | return fmt.Sprintf("error: %s ", v.Title) + strings.Join(messages, "\n") 40 | } 41 | 42 | // ValidationError が空かを判定 43 | func (v ValidationError) IsEmpty() bool { 44 | return len(v.Errors) == 0 45 | } 46 | 47 | // レスポンスを検証するバリデータ関数の型 48 | type ResponseValidator func(*http.Response) error 49 | 50 | // レスポンスを検証する関数 51 | // 複数のバリデータ関数を受け取ってすべてでレスポンスを検証し、 ValidationError を返す 52 | func ValidateResponse(title string, step *isucandar.BenchmarkStep, res *http.Response, err error, validators ...ResponseValidator) ValidationError { 53 | ve := ValidationError{} 54 | ve.Title = title 55 | defer func() { 56 | ve.Add(step) 57 | }() 58 | if err != nil { 59 | if failure.Is(err, context.DeadlineExceeded) || failure.Is(err, context.Canceled) { 60 | // ベンチが終了したタイミングのerrは無視してよい 61 | return ve 62 | } 63 | // リクエストがエラーだったらそれ以上の検証はしない(できない) 64 | ve.Errors = append(ve.Errors, failure.NewError(ErrInvalidRequest, err)) 65 | ContestantLogger.Print(ve.Error()) 66 | return ve 67 | } else { 68 | if Debug { 69 | AdminLogger.Printf("%s %s %d %s", res.Request.Method, res.Request.URL.Path, res.StatusCode, title) 70 | } 71 | defer res.Body.Close() 72 | } 73 | for _, v := range validators { 74 | if err := v(res); err != nil { 75 | ve.Errors = append(ve.Errors, failure.NewError(ErrValidation, err)) 76 | break // 前から順に検証、失敗したらそれ以上の検証はしない 77 | } 78 | } 79 | if !ve.IsEmpty() { 80 | ContestantLogger.Print(ve.Error()) 81 | } 82 | return ve 83 | } 84 | 85 | func WithCacheControlPrivate() ResponseValidator { 86 | return func(r *http.Response) error { 87 | if !strings.Contains(r.Header.Get("Cache-Control"), "private") { 88 | return failure.NewError(ErrInvalidCacheControl, fmt.Errorf("Cache-Control: private が含まれていません")) 89 | } 90 | return nil 91 | } 92 | } 93 | 94 | // ステータスコードコードを検証するバリデータ関数を返す高階関数 95 | // 例: ValidateResponse(res, WithStatusCode(200, 304)) 96 | func WithStatusCode(statusCodes ...int) ResponseValidator { 97 | return func(r *http.Response) error { 98 | for _, statusCode := range statusCodes { 99 | // 1個でも一致したらok 100 | if r.StatusCode == statusCode { 101 | return nil 102 | } 103 | } 104 | // ステータスコードが一致しなければ HTTP メソッド、URL パス、期待したステータスコード、実際のステータスコードを持つ 105 | // エラーを返す 106 | return failure.NewError( 107 | ErrInvalidStatusCode, 108 | fmt.Errorf( 109 | "%s %s : expected(%v) != actual(%d)", 110 | r.Request.Method, 111 | r.Request.URL.Path, 112 | statusCodes, 113 | r.StatusCode, 114 | ), 115 | ) 116 | } 117 | } 118 | 119 | func (v ValidationError) Add(step *isucandar.BenchmarkStep) { 120 | for _, err := range v.Errors { 121 | if err != nil { 122 | // 中身が ValidationError なら展開 123 | if ve, ok := err.(ValidationError); ok { 124 | ve.Add(step) 125 | } else { 126 | step.AddError(err) 127 | } 128 | } 129 | } 130 | } 131 | 132 | type ResponseAPIBase struct { 133 | Result bool `json:"result"` 134 | Status int `json:"status"` 135 | Error string `json:"error"` 136 | } 137 | 138 | func (r ResponseAPIBase) IsSuccess() bool { 139 | return r.Result 140 | } 141 | 142 | func (r ResponseAPIBase) ErrorMessage() string { 143 | return r.Error 144 | } 145 | 146 | type ResponseAPI interface { 147 | ResponseAPIBase | ResponseAPIGetPlaylists | ResponseAPIAddPlaylist | 148 | ResponseAPIGetPlaylist | ResposeUpdatePlaylist | ResponseAPIGetRecentPlaylists | 149 | ResponseAPIGetPopularPlaylists | 150 | ResponseAdminBan 151 | IsSuccess() bool 152 | ErrorMessage() string 153 | } 154 | 155 | func WithSuccessResponse[T ResponseAPI](validates ...func(res T) error) ResponseValidator { 156 | return func(r *http.Response) error { 157 | var v T 158 | if err := json.NewDecoder(r.Body).Decode(&v); err != nil { 159 | if failure.Is(err, context.DeadlineExceeded) || failure.Is(err, context.Canceled) { 160 | return nil 161 | } 162 | return failure.NewError( 163 | ErrInvalidJSON, 164 | fmt.Errorf("JSONのdecodeに失敗しました %s %s %s status %d", err, r.Request.Method, r.Request.URL.Path, r.StatusCode), 165 | ) 166 | } 167 | if !v.IsSuccess() { 168 | return failure.NewError( 169 | ErrFailed, 170 | fmt.Errorf("成功したAPIレスポンスの.resultはtrueである必要があります %s %s status %d", r.Request.Method, r.Request.URL.Path, r.StatusCode), 171 | ) 172 | } 173 | for _, validate := range validates { 174 | if err := validate(v); err != nil { 175 | b, _ := json.Marshal(v) 176 | AdminLogger.Println(string(b)) 177 | return failure.NewError( 178 | ErrFailed, 179 | fmt.Errorf("%s %s %s", r.Request.Method, r.Request.URL.Path, err.Error()), 180 | ) 181 | } 182 | } 183 | return nil 184 | } 185 | } 186 | 187 | func WithErrorResponse[T ResponseAPI]() ResponseValidator { 188 | return func(r *http.Response) error { 189 | var v T 190 | if err := json.NewDecoder(r.Body).Decode(&v); err != nil { 191 | if failure.Is(err, context.DeadlineExceeded) || failure.Is(err, context.Canceled) { 192 | return nil 193 | } 194 | return failure.NewError( 195 | ErrInvalidJSON, 196 | fmt.Errorf("JSONのdecodeに失敗しました %s %s status %d", r.Request.Method, r.Request.URL.Path, r.StatusCode), 197 | ) 198 | } 199 | if v.IsSuccess() { 200 | return failure.NewError( 201 | ErrFailed, 202 | fmt.Errorf("失敗したAPIレスポンスの.resultはfalseである必要があります %s %s %d", r.Request.Method, r.Request.URL.Path, r.StatusCode), 203 | ) 204 | } 205 | if v.ErrorMessage() == "" { 206 | return failure.NewError( 207 | ErrFailed, 208 | fmt.Errorf("失敗したAPIレスポンスの.errorにはエラーメッセージが必要です %s %s %d", r.Request.Method, r.Request.URL.Path, r.StatusCode), 209 | ) 210 | } 211 | return nil 212 | } 213 | } 214 | 215 | type ResponseAPIGetRecentPlaylists struct { 216 | ResponseAPIBase 217 | Playlists []Playlist `json:"playlists"` 218 | } 219 | 220 | type ResponseAPIGetPopularPlaylists struct { 221 | ResponseAPIBase 222 | Playlists []Playlist `json:"playlists"` 223 | } 224 | 225 | type ResponseAPIGetPlaylists struct { 226 | ResponseAPIBase 227 | CreatedPlaylists []Playlist `json:"created_playlists"` 228 | FavoritedPlaylists []Playlist `json:"favorited_playlists"` 229 | } 230 | 231 | type ResponseAPIAddPlaylist struct { 232 | ResponseAPIBase 233 | PlaylistULID string `json:"playlist_ulid"` 234 | } 235 | 236 | type ResponseAPIGetPlaylist struct { 237 | ResponseAPIBase 238 | Playlist Playlist `json:"playlist"` 239 | } 240 | 241 | type ResposeUpdatePlaylist struct { 242 | ResponseAPIBase 243 | Name string `json:"name"` 244 | IsPublic bool `json:"is_public"` 245 | SongUILDs []string `json:"song_ulids"` 246 | } 247 | 248 | type ResponseAdminBan struct { 249 | ResponseAPIBase 250 | UserAccount string `json:"user_account"` 251 | DisplayName string `json:"display_name"` 252 | IsBan bool `json:"is_ban"` 253 | } 254 | -------------------------------------------------------------------------------- /bench/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/Songmu/timeout v0.4.0 h1:7qUlKeO2neby/Htk9bYYd9w6VSj5MDkE6jnwGZV5zmU= 3 | github.com/Songmu/timeout v0.4.0/go.mod h1:lS4MuG+s4DJ+RvC+lmvhPTRjIRbZfqdP7K4NURzZVcg= 4 | github.com/Songmu/wrapcommander v0.1.0 h1:y8/yk9/PHT983weH+ehZIOJ7JtwAlI1AkfUpUNCj1SY= 5 | github.com/Songmu/wrapcommander v0.1.0/go.mod h1:EC2y4OnN8PkdMnaCwcSzItewq+f0yqUvS30kcS4vmn0= 6 | github.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4= 7 | github.com/aws/aws-lambda-go v1.26.0 h1:6ujqBpYF7tdZcBvPIccs98SpeGfrt/UOVEiexfNIdHA= 8 | github.com/aws/aws-lambda-go v1.26.0/go.mod h1:jJmlefzPfGnckuHdXX7/80O3BvUUi12XOkbv4w9SGLU= 9 | github.com/aws/aws-sdk-go v1.43.45 h1:2708Bj4uV+ym62MOtBnErm/CDX61C4mFe9V2gXy1caE= 10 | github.com/aws/aws-sdk-go v1.43.45/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 12 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= 17 | github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= 18 | github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= 19 | github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= 20 | github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= 21 | github.com/fujiwara/ridge v0.6.1 h1:FYsmfa2R288CQYa/U+pISkzCmZxmAICaaceiCqpKsXs= 22 | github.com/fujiwara/ridge v0.6.1/go.mod h1:eWW1sRrQEo/toVnrkziStLWOlDf1UdjuMc+ApZSwc6c= 23 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 24 | github.com/isucon/isucandar v0.0.0-20220322062028-6dd56dc57d72 h1:mCWYjY0ZaMGAFqwCC/hsEZZl+R8mymuVIRhmVO8r/EE= 25 | github.com/isucon/isucandar v0.0.0-20220322062028-6dd56dc57d72/go.mod h1:1j6H6zxOUW/sSAOGay1iE0CW6mM58U/OTIKvKXwbRaQ= 26 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 27 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 28 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 29 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 30 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 31 | github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= 32 | github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= 33 | github.com/labstack/echo/v4 v4.7.2 h1:Kv2/p8OaQ+M6Ex4eGimg9b9e6icoxA42JSlOR3msKtI= 34 | github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o= 35 | github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs= 36 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 37 | github.com/natureglobal/realip v0.0.2 h1:GK9jF+toi2pbTJqtg0PYAV5kCPQmhRD5w9PpD9eUiy0= 38 | github.com/natureglobal/realip v0.0.2/go.mod h1:DCUyqS3KN/rtSg3EY10GAETzSycmZBhnrFl//FMe/fw= 39 | github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc= 40 | github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68= 41 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 42 | github.com/pires/go-proxyproto v0.6.0/go.mod h1:Odh9VFOZJCf9G8cLW5o435Xf1J95Jw9Gw5rnCjcwzAY= 43 | github.com/pires/go-proxyproto v0.6.1 h1:EBupykFmo22SDjv4fQVQd2J9NOoLPmyZA/15ldOGkPw= 44 | github.com/pires/go-proxyproto v0.6.1/go.mod h1:Odh9VFOZJCf9G8cLW5o435Xf1J95Jw9Gw5rnCjcwzAY= 45 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 46 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= 50 | github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= 51 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 52 | github.com/samber/lo v1.12.0 h1:UZXQYR2N6FST+QWxnjV7gPmT9KkXL9eWbZxkrefVs88= 53 | github.com/samber/lo v1.12.0/go.mod h1:2I7tgIv8Q1SG2xEIkRq0F2i2zgxVpnyPOP0d3Gj2r+A= 54 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 55 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 56 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 57 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 58 | github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= 59 | github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= 60 | github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= 61 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 62 | github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= 63 | golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= 64 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= 65 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= 66 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 67 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 68 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 69 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 72 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 73 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 74 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 75 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= 76 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 77 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 78 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 79 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 80 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 81 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 82 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 83 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 84 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 85 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 86 | -------------------------------------------------------------------------------- /bench/scenario.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "sync" 8 | "sync/atomic" 9 | "time" 10 | 11 | mapset "github.com/deckarep/golang-set" 12 | "github.com/isucon/isucandar" 13 | "github.com/isucon/isucandar/failure" 14 | "github.com/isucon/isucandar/score" 15 | "github.com/isucon/isucandar/worker" 16 | ) 17 | 18 | var ( 19 | Debug = false 20 | MaxErrors = 30 21 | ) 22 | 23 | const ( 24 | ErrFailedLoadJSON failure.StringCode = "load-json" 25 | ErrCannotNewAgent failure.StringCode = "agent" 26 | ErrInvalidRequest failure.StringCode = "request" 27 | ) 28 | 29 | // シナリオで発生するスコアのタグ 30 | const ( 31 | ScoreGETRoot score.ScoreTag = "GET /" 32 | ScoreSignup score.ScoreTag = "POST /api/signup" 33 | ScoreLogin score.ScoreTag = "POST /api/login" 34 | ScoreLogout score.ScoreTag = "POST /api/logout" 35 | ScoreGetPlaylist score.ScoreTag = "GET /api/playlist/{}" 36 | ScoreGetPlaylists score.ScoreTag = "GET /api/playlists" 37 | ScoreGetRecentPlaylists score.ScoreTag = "GET /api/recent_playlists" 38 | ScoreGetPopularPlaylists score.ScoreTag = "GET /api/popular_playlists" 39 | ScoreAddPlaylist score.ScoreTag = "POST /api/playlist/{}/add" 40 | ScoreUpdatePlaylist score.ScoreTag = "POST /api/playlist/{}/update" 41 | ScoreFavoritePlaylist score.ScoreTag = "POST /api/playlist/favorite" 42 | ScoreAdminBan score.ScoreTag = "POST /api/admin/user/ban" 43 | 44 | ScoreGetRecentPlaylistsLogin score.ScoreTag = "GET /api/recent_playlists (login)" 45 | ScoreGetPopularPlaylistsLogin score.ScoreTag = "GET /api/popular_playlists (login)" 46 | ) 47 | 48 | // オプションと全データを持つシナリオ構造体 49 | type Scenario struct { 50 | mu sync.RWMutex 51 | 52 | Option Option 53 | 54 | NormalUsers mapset.Set 55 | HeavyUsers mapset.Set 56 | BannedUsers mapset.Set 57 | Songs Songs 58 | AdminUser *User 59 | 60 | lastPlaylistCreatedAt time.Time 61 | rateGetPopularPlaylists int32 62 | 63 | Errors failure.Errors 64 | } 65 | 66 | func (s *Scenario) SetRateGetPopularPlaylists(rate int32) { 67 | AdminLogger.Printf("set rate get popular playlists to %d", rate) 68 | atomic.StoreInt32(&s.rateGetPopularPlaylists, rate) 69 | } 70 | 71 | func (s *Scenario) RateGetPopularPlaylists() int32 { 72 | return atomic.LoadInt32(&s.rateGetPopularPlaylists) 73 | } 74 | 75 | func (s *Scenario) SetLastPublicPlaylistCreatedAt(t time.Time) { 76 | s.mu.Lock() 77 | defer s.mu.Unlock() 78 | s.lastPlaylistCreatedAt = t 79 | } 80 | 81 | func (s *Scenario) LastPublicPlaylistCreatedAt() time.Time { 82 | s.mu.RLock() 83 | defer s.mu.RUnlock() 84 | return s.lastPlaylistCreatedAt 85 | } 86 | 87 | // isucandar.PrepeareScenario を満たすメソッド 88 | // isucandar.Benchmark の Prepare ステップで実行される 89 | func (s *Scenario) Prepare(ctx context.Context, step *isucandar.BenchmarkStep) error { 90 | s.SetRateGetPopularPlaylists(10) 91 | 92 | // Userのロード 93 | us, err := LoadFromJSONFile[User](filepath.Join(s.Option.DataDir, "users.json")) 94 | if err != nil { 95 | return err 96 | } 97 | AdminLogger.Printf("%d users loaded", len(us)) 98 | s.AdminUser = &User{Account: "adminuser", Password: "adminpass"} 99 | s.NormalUsers = mapset.NewSet() 100 | s.HeavyUsers = mapset.NewSet() 101 | s.BannedUsers = mapset.NewSet() 102 | for _, u := range us { 103 | if u.IsBan { 104 | s.BannedUsers.Add(u) 105 | // banされてたらheavyにいれない 106 | continue 107 | } 108 | if u.IsHeavy { 109 | s.HeavyUsers.Add(u) 110 | } else { 111 | s.NormalUsers.Add(u) 112 | } 113 | } 114 | AdminLogger.Printf( 115 | "normal:%d heavy:%d banned:%d", 116 | s.NormalUsers.Cardinality(), 117 | s.HeavyUsers.Cardinality(), 118 | s.BannedUsers.Cardinality(), 119 | ) 120 | 121 | if s.Songs, err = LoadFromJSONFile[Song](filepath.Join(s.Option.DataDir, "songs.json")); err != nil { 122 | return err 123 | } 124 | AdminLogger.Printf("%d songs loaded", len(s.Songs)) 125 | 126 | // Prepareは60秒以内に完了 127 | ctx, cancel := context.WithTimeout(ctx, time.Minute) 128 | defer cancel() 129 | 130 | // GET /initialize 用ユーザーエージェントの生成 131 | ag, err := s.Option.NewAgent(true) 132 | if err != nil { 133 | return failure.NewError(ErrCannotNewAgent, err) 134 | } 135 | 136 | if s.Option.SkipPrepare { 137 | return nil 138 | } 139 | 140 | debug := Debug 141 | defer func() { 142 | Debug = debug 143 | }() 144 | Debug = true // prepareは常にデバッグログを出す 145 | 146 | // POST /initialize へ初期化リクエスト実行 147 | res, err := GetInitializeAction(ctx, ag) 148 | if v := ValidateResponse("初期化", step, res, err, WithStatusCode(200)); !v.IsEmpty() { 149 | return fmt.Errorf("初期化リクエストに失敗しました %v", v) 150 | } 151 | 152 | // 検証シナリオを1回まわす 153 | if err := s.ValidationScenario(ctx, step); err != nil { 154 | return fmt.Errorf("整合性チェックに失敗しました") 155 | } 156 | 157 | ContestantLogger.Printf("整合性チェックに成功しました") 158 | return nil 159 | } 160 | 161 | // isucandar.PrepeareScenario を満たすメソッド 162 | // isucandar.Benchmark の Load ステップで実行される 163 | func (s *Scenario) Load(ctx context.Context, step *isucandar.BenchmarkStep) error { 164 | if s.Option.PrepareOnly { 165 | return nil 166 | } 167 | ContestantLogger.Println("負荷テストを開始します") 168 | defer ContestantLogger.Println("負荷テストを終了します") 169 | wg := &sync.WaitGroup{} 170 | 171 | // 通常シナリオ 172 | normalCase, err := s.NormalWorker(step, 1) 173 | if err != nil { 174 | return err 175 | } 176 | // favolite を追加するケースのシナリオ 177 | favCase, err := s.FavoriteWorker(step, 1) 178 | if err != nil { 179 | return err 180 | } 181 | // banされたユーザーのシナリオ 182 | bannedCase, err := s.BannedWorker(step, 1) 183 | if err != nil { 184 | return err 185 | } 186 | // 匿名シナリオ 187 | anonCase, err := s.AnonWorker(step, 1) 188 | if err != nil { 189 | return err 190 | } 191 | 192 | workers := []*worker.Worker{ 193 | normalCase, 194 | favCase, 195 | bannedCase, 196 | anonCase, 197 | } 198 | for _, w := range workers { 199 | wg.Add(1) 200 | worker := w 201 | go func() { 202 | defer wg.Done() 203 | worker.Process(ctx) 204 | }() 205 | } 206 | wg.Add(1) 207 | go func() { 208 | defer wg.Done() 209 | s.loadAdjustor(ctx, step, normalCase, favCase, anonCase) 210 | }() 211 | wg.Wait() 212 | return nil 213 | } 214 | 215 | func (s *Scenario) loadAdjustor(ctx context.Context, step *isucandar.BenchmarkStep, workers ...*worker.Worker) { 216 | tk := time.NewTicker(time.Second) 217 | var prevErrors int64 218 | for { 219 | select { 220 | case <-ctx.Done(): 221 | return 222 | case <-tk.C: 223 | } 224 | errors := step.Result().Errors.Count() 225 | total := errors["load"] 226 | if total >= int64(MaxErrors) { 227 | ContestantLogger.Printf("負荷テストを打ち切ります (エラー数:%d)", total) 228 | AdminLogger.Printf("%#v", errors) 229 | step.Result().Score.Close() 230 | step.Cancel() 231 | return 232 | } 233 | addParallels := int32(1) 234 | if diff := total - prevErrors; diff > 0 { 235 | ContestantLogger.Printf("エラーが%d件増えました(現在%d件)", diff, total) 236 | } else { 237 | ContestantLogger.Println("ユーザーが増えます") 238 | 239 | // popularの取得確率も増やしていく 240 | rate := s.RateGetPopularPlaylists() 241 | if rate < 100 { 242 | // 2回増えると2倍になるペースで増加 243 | // 10 -> 14 -> 20 -> 28 -> 40 ... 244 | newRate := int32(float32(rate) * 1.41422) 245 | if newRate >= 100 { 246 | newRate = 100 247 | } 248 | s.SetRateGetPopularPlaylists(newRate) 249 | } 250 | 251 | addParallels = 1 252 | } 253 | for _, w := range workers { 254 | w.AddParallelism(addParallels) 255 | } 256 | prevErrors = total 257 | } 258 | } 259 | 260 | func (s *Scenario) ChoiceUser(ctx context.Context, pool mapset.Set) (*User, func()) { 261 | for { 262 | select { 263 | case <-ctx.Done(): 264 | return nil, func() {} 265 | default: 266 | } 267 | if u := pool.Pop(); u == nil { 268 | time.Sleep(time.Second) 269 | continue 270 | } else { 271 | user := u.(*User) 272 | return user, func() { 273 | ag, _ := user.GetAgent(s.Option) 274 | ag.HttpClient.CloseIdleConnections() 275 | pool.Add(u) 276 | } 277 | } 278 | } 279 | } 280 | 281 | var nullFunc = func() {} 282 | 283 | func timeReporter(name string) func() { 284 | if !Debug { 285 | return nullFunc 286 | } 287 | start := time.Now() 288 | return func() { 289 | AdminLogger.Printf("Scenario:%s elapsed:%s", name, time.Since(start)) 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /bench/scenario_normal.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "time" 8 | 9 | "github.com/isucon/isucandar" 10 | "github.com/isucon/isucandar/worker" 11 | "github.com/samber/lo" 12 | ) 13 | 14 | func (s *Scenario) NormalWorker(step *isucandar.BenchmarkStep, p int32) (*worker.Worker, error) { 15 | w, err := worker.NewWorker(func(ctx context.Context, _ int) { 16 | s.NormalScenario(ctx, step) 17 | }, 18 | // 無限回繰り返す 19 | worker.WithInfinityLoop(), 20 | worker.WithUnlimitedParallelism(), 21 | ) 22 | if err != nil { 23 | return nil, err 24 | } 25 | w.SetParallelism(p) 26 | return w, nil 27 | } 28 | 29 | // 普通のUser 30 | func (s *Scenario) NormalScenario(ctx context.Context, step *isucandar.BenchmarkStep) error { 31 | report := timeReporter("normal") 32 | defer report() 33 | 34 | user, release := s.ChoiceUser(ctx, s.NormalUsers) 35 | if user == nil { 36 | return nil 37 | } 38 | defer release() 39 | ag, _ := user.GetAgent(s.Option) 40 | { 41 | // ログイン 42 | res, err := LoginAction(ctx, user, ag) 43 | v := ValidateResponse("ログイン", 44 | step, res, err, 45 | WithStatusCode(200), 46 | WithSuccessResponse[ResponseAPIBase](), 47 | ) 48 | if v.IsEmpty() { 49 | step.AddScore(ScoreLogin) 50 | } else { 51 | return v 52 | } 53 | } 54 | if rand.Int31n(100) < s.RateGetPopularPlaylists() { // popularは確率で取る 55 | res, err := GetPopularPlaylistsAction(ctx, ag) 56 | v := ValidateResponse("人気プレイリスト一覧", 57 | step, res, err, 58 | WithStatusCode(200), 59 | WithCacheControlPrivate(), 60 | WithSuccessResponse(func(r ResponseAPIGetPopularPlaylists) error { 61 | if len(r.Playlists) > 100 { 62 | return fmt.Errorf("人気プレイリスト一覧が100件を超えています %d", len(r.Playlists)) 63 | } 64 | if len(r.Playlists) == 0 { 65 | return fmt.Errorf("人気プレイリスト一覧が空です") 66 | } 67 | return nil 68 | }), 69 | ) 70 | if v.IsEmpty() { 71 | step.AddScore(ScoreGetPopularPlaylistsLogin) 72 | } else { 73 | return v 74 | } 75 | } 76 | var playlistULIDs []string 77 | { 78 | // プレイリスト一覧 79 | res, err := GetPlaylistsAction(ctx, ag) 80 | v := ValidateResponse("自分のプレイリスト一覧", 81 | step, res, err, 82 | WithStatusCode(200), 83 | WithCacheControlPrivate(), 84 | WithSuccessResponse(func(r ResponseAPIGetPlaylists) error { 85 | for _, p := range r.CreatedPlaylists { 86 | playlistULIDs = append(playlistULIDs, p.ULID) 87 | } 88 | for _, p := range r.FavoritedPlaylists { 89 | playlistULIDs = append(playlistULIDs, p.ULID) 90 | } 91 | return nil 92 | }), 93 | ) 94 | 95 | if v.IsEmpty() { 96 | step.AddScore(ScoreGetPlaylists) 97 | } else { 98 | return v 99 | } 100 | } 101 | var favULIDS []string 102 | // 自分のプレイリスト詳細を1/4ぐらい取る 103 | playlistULIDs = lo.Samples(playlistULIDs, len(playlistULIDs)/4+1) 104 | for _, playlistULID := range playlistULIDs { 105 | res, err := GetPlaylistAction(ctx, playlistULID, ag) 106 | v := ValidateResponse("プレイリスト詳細(ログイン中)", 107 | step, res, err, 108 | WithStatusCode(200), 109 | WithCacheControlPrivate(), 110 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 111 | p := r.Playlist 112 | if rand.Int()%10 == 0 { // 10%の確率でfavを付け外し 113 | favULIDS = append(favULIDS, p.ULID) 114 | } 115 | return nil 116 | }), 117 | ) 118 | if v.IsEmpty() { 119 | step.AddScore(ScoreGetPlaylist) 120 | } else { 121 | return v 122 | } 123 | } 124 | for _, id := range favULIDS { 125 | isFav := rand.Int()%2 == 0 126 | res, err := FavoritePlaylistAction(ctx, &Playlist{ULID: id, IsFavorited: isFav}, ag) 127 | v := ValidateResponse("プレイリストにfavする", 128 | step, res, err, 129 | WithStatusCode(200), 130 | WithCacheControlPrivate(), 131 | ) 132 | if v.IsEmpty() { 133 | step.AddScore(ScoreFavoritePlaylist) 134 | } else { 135 | return v 136 | } 137 | } 138 | var playlistULID string 139 | { 140 | // 新規プレイリスト作成 141 | playlistName := DisplayName() 142 | res, err := AddPlayistAction(ctx, playlistName, ag) 143 | v := ValidateResponse("新規プレイリスト作成", 144 | step, res, err, 145 | WithStatusCode(200), 146 | WithCacheControlPrivate(), 147 | WithSuccessResponse(func(r ResponseAPIAddPlaylist) error { 148 | if r.PlaylistULID == "" { 149 | return fmt.Errorf("作成されたプレイリストのULIDが空です") 150 | } 151 | playlistULID = r.PlaylistULID 152 | return nil 153 | }), 154 | ) 155 | if v.IsEmpty() { 156 | step.AddScore(ScoreAddPlaylist) 157 | } else { 158 | return v 159 | } 160 | } 161 | { 162 | // プレイリスト一覧 163 | res, err := GetPlaylistsAction(ctx, ag) 164 | v := ValidateResponse("自分のプレイリスト一覧", 165 | step, res, err, 166 | WithStatusCode(200), 167 | WithCacheControlPrivate(), 168 | WithSuccessResponse(func(r ResponseAPIGetPlaylists) error { 169 | if len(r.CreatedPlaylists) == 0 { 170 | return fmt.Errorf("作成済みプレイリストが0件です") 171 | } 172 | var found bool 173 | for _, p := range r.CreatedPlaylists { 174 | if p.ULID == playlistULID { 175 | if p.SongCount != 0 { 176 | return fmt.Errorf("作成済みプレイリストの曲数が想定外です") 177 | } 178 | found = true 179 | break 180 | } 181 | } 182 | if !found { 183 | return fmt.Errorf("作成したプレイリストが見つかりません") 184 | } 185 | return nil 186 | }), 187 | ) 188 | if v.IsEmpty() { 189 | step.AddScore(ScoreGetPlaylists) 190 | } else { 191 | return v 192 | } 193 | } 194 | var playlist Playlist 195 | { 196 | // プレイリスト詳細 作成直後なので空 197 | res, err := GetPlaylistAction(ctx, playlistULID, ag) 198 | v := ValidateResponse("プレイリスト詳細(ログイン中)", 199 | step, res, err, 200 | WithStatusCode(200), 201 | WithCacheControlPrivate(), 202 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 203 | if r.Playlist.ULID != playlistULID { 204 | return fmt.Errorf("ULIDが一致しません") 205 | } 206 | playlist = r.Playlist 207 | return nil 208 | }), 209 | ) 210 | if v.IsEmpty() { 211 | step.AddScore(ScoreGetPlaylist) 212 | } else { 213 | return v 214 | } 215 | } 216 | addSongs := lo.Samples(s.Songs, rand.Intn(80)) 217 | { 218 | // プレイリスト更新、n曲追加 公開にする 219 | playlist.Songs = append(playlist.Songs, addSongs...) 220 | playlist.IsPublic = true 221 | res, err := UpdatePlayistAction(ctx, &playlist, ag) 222 | v := ValidateResponse("プレイリスト更新 曲追加 公開にする", 223 | step, res, err, 224 | WithStatusCode(200), 225 | WithCacheControlPrivate(), 226 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 227 | if r.Playlist.ULID != playlistULID { 228 | return fmt.Errorf("プレイリストのULIDが一致しません") 229 | } 230 | if r.Playlist.SongCount != len(addSongs) { 231 | return fmt.Errorf("%d曲追加したはずですが%d曲になっています", len(addSongs), r.Playlist.SongCount) 232 | } 233 | expectd := playlist.Songs.ULIDs() 234 | for i, ret := range r.Playlist.Songs.ULIDs() { 235 | if ret != expectd[i] { 236 | return fmt.Errorf("%d番目の曲 %s が、追加した曲 %s と一致しません", i+1, ret, expectd[i]) 237 | } 238 | } 239 | // 公開にしたので最新時刻を設定 240 | s.SetLastPublicPlaylistCreatedAt(r.Playlist.CreatedAt) 241 | return nil 242 | }), 243 | ) 244 | if v.IsEmpty() { 245 | step.AddScore(ScoreUpdatePlaylist) 246 | } else { 247 | return v 248 | } 249 | } 250 | { 251 | // プレイリスト一覧を取り直す。曲が入っているはず 252 | res, err := GetPlaylistsAction(ctx, ag) 253 | v := ValidateResponse("プレイリスト一覧", 254 | step, res, err, 255 | WithStatusCode(200), 256 | WithCacheControlPrivate(), 257 | WithSuccessResponse(func(r ResponseAPIGetPlaylists) error { 258 | if len(r.CreatedPlaylists) == 0 { 259 | return fmt.Errorf("作成済みプレイリストが0件です") 260 | } 261 | var found bool 262 | for _, p := range r.CreatedPlaylists { 263 | if p.ULID == playlistULID { 264 | if p.SongCount != len(addSongs) { 265 | return fmt.Errorf("作成済みプレイリストの曲数が想定外です") 266 | } 267 | found = true 268 | break 269 | } 270 | } 271 | if !found { 272 | return fmt.Errorf("作成されたプレイリスト %s が作成済みプレイリストにありません", playlistULID) 273 | } 274 | return nil 275 | }), 276 | ) 277 | if v.IsEmpty() { 278 | step.AddScore(ScoreGetPlaylists) 279 | } else { 280 | return v 281 | } 282 | } 283 | { 284 | // 最新プレイリスト一覧を見に行く(投稿したばかりなのであるはず) 285 | res, err := GetRecentPlaylistsAction(ctx, ag) 286 | v := ValidateResponse("最新プレイリスト一覧", 287 | step, res, err, 288 | WithStatusCode(200), 289 | WithCacheControlPrivate(), 290 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 291 | var found bool 292 | var oldestCreatedAt time.Time 293 | for _, p := range r.Playlists { 294 | if p.ULID == playlistULID { 295 | if p.SongCount != len(addSongs) { 296 | return fmt.Errorf("プレイリストの %s 曲数(%d)が違います", playlistULID, p.SongCount) 297 | } 298 | found = true 299 | break 300 | } 301 | oldestCreatedAt = p.CreatedAt 302 | } 303 | // top100に見つからない 304 | // 既に100件以上投稿されて押し出されている場合は見つからないのでoldestと比較も必要 305 | if !found && oldestCreatedAt.After(playlist.CreatedAt) { 306 | return fmt.Errorf("最新プレイリストにプレイリスト %s が見つかりません", playlistULID) 307 | } 308 | return nil 309 | }), 310 | ) 311 | if v.IsEmpty() { 312 | step.AddScore(ScoreGetRecentPlaylistsLogin) 313 | } else { 314 | return v 315 | } 316 | } 317 | 318 | return nil 319 | } 320 | -------------------------------------------------------------------------------- /data/build_fake_data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import time 3 | from faker import Faker 4 | import bcrypt 5 | import ulid 6 | import random 7 | import mysql.connector 8 | import csv 9 | import json 10 | import os 11 | import sys 12 | import numpy as np 13 | import traceback 14 | 15 | random.seed(42) # fix seed 16 | args = sys.argv 17 | salt = bcrypt.gensalt(rounds=4) 18 | 19 | fake = Faker(['en-US', 'ja-JP']) 20 | fake.seed_instance(time.monotonic_ns()) 21 | fake_en = Faker(['en-US']) 22 | fake_en.seed_instance(time.monotonic_ns()) 23 | 24 | tick = 0 25 | counter = ["|", "/", "-", "\\"] 26 | def print_counter(): 27 | global tick 28 | tick += 1 29 | print("\r" + counter[tick % len(counter)] + " " + str(tick), end="") 30 | 31 | print('make fake words') 32 | fake_words = fake.words(nb=10000) 33 | print('make fake en words') 34 | fake_words_en = fake_en.words(nb=10000) 35 | print('make fake passwords') 36 | fake_passwords = list() 37 | for i in range(1, 100): 38 | raw = fake.password(special_chars=False) 39 | fake_passwords.append([bcrypt.hashpw(raw.encode('utf-8'), salt), raw]) 40 | # print_counter() 41 | 42 | # 作成数 43 | cycle = 10 # 分割数 44 | sets = int(args[1]) if len(args) > 1 else 100000 # 最終的に欲しいセット数 45 | 46 | # 一周で作る数 47 | user_num = int(sets / cycle) * 1 48 | artist_num = int(sets / cycle) * 1 49 | 50 | # 生成数上限 51 | max_album_num_each_artist = 20 52 | max_song_num_each_album = 15 53 | max_playlist_num_each_user = 10000 54 | 55 | # アイテム生成数を適当にロングテールで求める 56 | rng = np.random.default_rng() 57 | powers = list(rng.power(1, 10000)) 58 | powers_idx = 0 59 | def num_of_items(maxn): 60 | global powers_idx 61 | powers_idx += 1 62 | if powers_idx >= len(powers): 63 | powers_idx = 0 64 | return min(int(1 / powers[powers_idx]), maxn) 65 | 66 | # 行数 67 | # user = user 68 | user_rows = user_num 69 | # playlist = user * playlist/user 70 | # artist = artist 71 | artist_rows = artist_num 72 | # song = artist * album * song 73 | 74 | print('user_rows', user_rows) 75 | print('artist_rows', artist_rows) 76 | print('1 loop', sum) 77 | 78 | # exit() 79 | 80 | 81 | users = list() 82 | all_users = list() 83 | initial_users = [ 84 | { 85 | 'account': 'isucon', 86 | 'display_name': 'isucon', 87 | 'password_hash': '$2b$11$smSqDIk.wv4UlzxCFDHqeOD922guLSKoeHmjWgfzOttlaNu65xlKW', # isuconpass 88 | 'is_ban': False, 89 | 'created_at': '1970-01-01 00:00:01', 90 | 'last_logined_at': '1970-01-01 00:00:01', 91 | }, 92 | { 93 | 'account': 'dummy', 94 | 'display_name': 'dummypass', 95 | 'password_hash': '$2b$11$dUin2kiGvI8MBZxXTL2qweX6RTkm5LmHuLnmDAdEMx0YjBHRMa64q', # dummypass 96 | 'is_ban': False, 97 | 'created_at': '1970-01-01 00:00:01', 98 | 'last_logined_at': '1970-01-01 00:00:01', 99 | }, 100 | { 101 | 'account': 'adminuser', 102 | 'display_name': '管理者', 103 | 'password_hash': '$2b$12$ccW9mADvL5UhZS42HbqspOcYmo/im8ScJ2vkewkxywYRnd/bLVwrC', # adminpass 104 | 'is_ban': False, 105 | 'created_at': '1970-01-01 00:00:01', 106 | 'last_logined_at': '1970-01-01 00:00:01', 107 | }, 108 | ] 109 | users_json_data = list() 110 | playlists = list() 111 | artists = list() 112 | songs = list() 113 | playlist_songs = list() 114 | playlist_favorites = list() 115 | 116 | 117 | def make_playlist_song(): 118 | for p in exist_playlists: 119 | # print_counter() 120 | n = int(random.random() * 80) 121 | song_id_list = random.sample(exist_song_ids, n) 122 | for i, sid in enumerate(song_id_list): 123 | plsng = { 124 | 'playlist_id': p['id'], 125 | 'song_id': sid, 126 | 'sort_order': i + 1, 127 | } 128 | playlist_songs.append(plsng) 129 | 130 | def make_playlist_favorite(): 131 | for u in all_users: 132 | # print_counter() 133 | n = u['_favs'] 134 | fav_playlists = random.sample(exist_playlists, n) 135 | for p in fav_playlists: 136 | plfv = { 137 | 'playlist_id': p['id'], 138 | 'favorite_user_account': u['account'], 139 | 'created_at': fake.date_time_between(start_date=p['created_at'], end_date='now').isoformat(), 140 | } 141 | playlist_favorites.append(plfv) 142 | for p in exist_playlists: 143 | n = int(num_of_items(len(all_users)/2)) 144 | fav_user_account_list = random.sample(all_users, n) 145 | for user in fav_user_account_list: 146 | plfv = { 147 | 'playlist_id': p['id'], 148 | 'favorite_user_account': user['account'], 149 | 'created_at': fake.date_time_between(start_date=p['created_at'], end_date='now').isoformat(), 150 | } 151 | playlist_favorites.append(plfv) 152 | 153 | def make_user(): 154 | for user_index in range(1, user_num+1): 155 | # print_counter() 156 | user_account = ''.join(random.sample(fake_words_en, 3)) 157 | display_name = ''.join(random.sample(fake_words, 3)) 158 | password = random.choice(fake_passwords) 159 | created_at = fake.date_time_between(start_date='-1y', end_date='now') 160 | usr = { 161 | 'account': user_account, 162 | 'display_name': display_name, 163 | 'password_raw': password[1], 164 | 'password_hash': password[0].decode('utf-8'), 165 | 'is_ban': random.random() < 0.05, # 5% ban 166 | 'created_at': created_at.isoformat(), 167 | 'last_logined_at': fake.date_time_between(start_date=created_at, end_date='now').isoformat(), 168 | '_favs': num_of_items(max_playlist_num_each_user), 169 | } 170 | # print('\nplaylist...') 171 | n = num_of_items(max_playlist_num_each_user) 172 | for playlist_index in range(1, n): 173 | playlist_name = ''.join(random.sample(fake_words, 3)) 174 | created_at = fake.date_time_between(start_date='-1y', end_date='now') 175 | pl = { 176 | # id = playlist_index (auto inc) 177 | 'ulid': ulid.from_timestamp(created_at).str, 178 | 'name': playlist_name, 179 | 'user_account': user_account, 180 | 'is_public': random.choice([True, False]), 181 | 'created_at': created_at.isoformat(), 182 | 'updated_at': fake.date_time_between(start_date=created_at, end_date='now').isoformat(), 183 | } 184 | playlists.append(pl) 185 | 186 | users.append(usr) 187 | all_users.append(usr) 188 | users_json_data.append({ 189 | 'account': usr['account'], 190 | 'password': usr['password_raw'], 191 | 'is_ban': usr['is_ban'], 192 | 'is_heavy': (n > 100 or usr['_favs'] > 100), 193 | }) 194 | 195 | 196 | def make_song(): 197 | for artist_index in range(1, artist_num+1): 198 | # print_counter() 199 | artist_name = ''.join(random.sample(fake_words, 3)) 200 | ar = { 201 | # id auto increment 202 | 'ulid': ulid.new().str, 203 | 'name': artist_name 204 | } 205 | artists.append(ar) 206 | 207 | n = num_of_items(max_album_num_each_artist) 208 | for album_index in range(1, n+1): 209 | album_name = ' '.join(random.sample(fake_words, 2)) 210 | # print('\nalbum', album_name) 211 | 212 | m = num_of_items(max_song_num_each_album) 213 | for song_index in range(1, m+1): 214 | song_title = ''.join(random.sample(fake_words, 3)) 215 | sng = { 216 | # id auto increment 217 | 'ulid': ulid.new().str, 218 | 'title': song_title, 219 | 'artist_id': artist_index, 220 | 'artist_name': artist_name, 221 | 'album': album_name, 222 | 'track_number': song_index, 223 | 'is_public': True 224 | } 225 | songs.append(sng) 226 | 227 | 228 | # insert 229 | 230 | cnx = mysql.connector.connect( 231 | user='isucon', 232 | password='isucon', 233 | host='localhost', 234 | database = 'isucon_listen80', 235 | allow_local_infile=True 236 | ) 237 | 238 | if cnx.is_connected: 239 | print("Connected!") 240 | 241 | csr = cnx.cursor() 242 | 243 | try: 244 | for t in ['user', 'playlist', 'artist', 'song', 'playlist_song', 'playlist_favorite']: 245 | csr.execute(f'TRUNCATE {t}') 246 | 247 | for master in range(0, cycle): 248 | users = initial_users if master == 0 else list() 249 | playlists = list() 250 | artists = list() 251 | songs = list() 252 | playlist_songs = list() 253 | 254 | print(f"================ master {master} ==============") 255 | 256 | 257 | make_user() 258 | print('user num:', len(users)) 259 | with open('/tmp/user', 'w') as f: 260 | writer = csv.writer(f, lineterminator='\n') 261 | rows = list() 262 | for row in users: 263 | rows.append([row['account'], row['display_name'], row['password_hash'], ("1" if row['is_ban'] else "0"), row['created_at'], row['last_logined_at'], row['last_logined_at']]) 264 | writer.writerows(rows) 265 | query_load_user = (""" 266 | LOAD DATA LOCAL INFILE '/tmp/user' 267 | IGNORE 268 | INTO TABLE user 269 | FIELDS TERMINATED BY ',' 270 | LINES TERMINATED BY '\n' 271 | (`account`, `display_name`, `password_hash`, `is_ban`, `created_at`, `last_logined_at`); 272 | """) 273 | csr.execute(query_load_user) 274 | cnx.commit() 275 | 276 | print('playlist num:', len(playlists)) 277 | with open('/tmp/playlist', 'w') as f: 278 | writer = csv.writer(f, lineterminator='\n') 279 | rows = list() 280 | for row in playlists: 281 | rows.append([row['ulid'], row['name'], row['user_account'], int(row['is_public']), row['created_at'], row['updated_at']]) 282 | writer.writerows(rows) 283 | query_load_playlist = (""" 284 | LOAD DATA LOCAL INFILE '/tmp/playlist' 285 | IGNORE 286 | INTO TABLE playlist 287 | FIELDS TERMINATED BY ',' 288 | LINES TERMINATED BY '\n' 289 | (`ulid`, `name`, `user_account`, `is_public`, `created_at`, `updated_at`); 290 | """) 291 | csr.execute(query_load_playlist) 292 | cnx.commit() 293 | 294 | make_song() 295 | print('artist num:', len(artists)) 296 | with open('/tmp/artist', 'w') as f: 297 | writer = csv.writer(f, lineterminator='\n') 298 | rows = list() 299 | for row in artists: 300 | rows.append([row['ulid'], row['name']]) 301 | writer.writerows(rows) 302 | query_load_artist = (""" 303 | LOAD DATA LOCAL INFILE '/tmp/artist' 304 | IGNORE 305 | INTO TABLE artist 306 | FIELDS TERMINATED BY ',' 307 | LINES TERMINATED BY '\n' 308 | (`ulid`, `name`); 309 | """) 310 | csr.execute(query_load_artist) 311 | cnx.commit() 312 | 313 | print('song num:', len(songs)) 314 | with open('/tmp/song', 'w') as f: 315 | writer = csv.writer(f, lineterminator='\n') 316 | rows = list() 317 | for row in songs: 318 | rows.append([row['ulid'], row['title'], row['artist_id'], row['album'], row['track_number'], int(row['is_public'])]) 319 | writer.writerows(rows) 320 | query_load_song = (""" 321 | LOAD DATA LOCAL INFILE '/tmp/song' 322 | IGNORE 323 | INTO TABLE song 324 | FIELDS TERMINATED BY ',' 325 | LINES TERMINATED BY '\n' 326 | (`ulid`, `title`, `artist_id`, `album`, `track_number`, `is_public`); 327 | """) 328 | csr.execute(query_load_song) 329 | cnx.commit() 330 | 331 | try: 332 | os.remove('users.json') 333 | except Exception: 334 | pass 335 | with open('users.json', 'a') as users_json: 336 | json.dump(random.sample(users_json_data, min(10000, len(users_json_data))), users_json, indent=2) 337 | 338 | try: 339 | os.remove('songs.json') 340 | except Exception: 341 | pass 342 | with open('songs.json', 'a') as songs_json: 343 | json.dump(random.sample(songs, min(10000, len(songs))), songs_json, indent=2) 344 | 345 | query_select_playlist_ids = ("""SELECT id, created_at FROM playlist""") 346 | csr.execute(query_select_playlist_ids) 347 | res = csr.fetchall() 348 | exist_playlists = [{'id': p[0], 'created_at': p[1]} for p in res] 349 | print('exist_playlists', len(exist_playlists)) 350 | 351 | query_select_song_ids = ("""SELECT id FROM song""") 352 | csr.execute(query_select_song_ids) 353 | res = csr.fetchall() 354 | exist_song_ids = [sid[0] for sid in res] 355 | print('exist_song_ids', len(exist_song_ids)) 356 | 357 | make_playlist_song() 358 | print('playlist song num:', len(playlist_songs)) 359 | with open('/tmp/playlist_song', 'w') as f: 360 | writer = csv.writer(f, lineterminator='\n') 361 | rows = list() 362 | for row in playlist_songs: 363 | rows.append([row['playlist_id'], row['song_id'], row['sort_order']]) 364 | writer.writerows(rows) 365 | 366 | query_load_playlist_song = (""" 367 | LOAD DATA LOCAL INFILE '/tmp/playlist_song' 368 | IGNORE 369 | INTO TABLE playlist_song 370 | FIELDS TERMINATED BY ',' 371 | LINES TERMINATED BY '\n' 372 | (`playlist_id`, `song_id`, `sort_order`); 373 | """) 374 | csr.execute(query_load_playlist_song) 375 | cnx.commit() 376 | 377 | make_playlist_favorite() 378 | print('playlist favorite num:', len(playlist_favorites)) 379 | 380 | with open('/tmp/playlist_favorite', 'w') as f: 381 | writer = csv.writer(f, lineterminator='\n') 382 | rows = list() 383 | for row in playlist_favorites: 384 | rows.append([row['playlist_id'], row['favorite_user_account'], row['created_at']]) 385 | writer.writerows(rows) 386 | 387 | query_load_playlist_favorite = (""" 388 | LOAD DATA LOCAL INFILE '/tmp/playlist_favorite' 389 | IGNORE 390 | INTO TABLE playlist_favorite 391 | FIELDS TERMINATED BY ',' 392 | LINES TERMINATED BY '\n' 393 | (`playlist_id`, `favorite_user_account`, `created_at`); 394 | """) 395 | csr.execute(query_load_playlist_favorite) 396 | cnx.commit() 397 | 398 | 399 | except Exception as e: 400 | print(f"Error Occurred: {e}") 401 | traceback.print_exc() 402 | 403 | 404 | finally: 405 | csr.close() 406 | cnx.close() 407 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | ## API 2 | 3 | ### 基本的なレスポンス型 4 | 5 | - 成功時 6 | ```json 7 | { 8 | "result": true, 9 | "status": 200 10 | } 11 | ``` 12 | 13 | - 失敗時 14 | ```json 15 | { 16 | "result": false, 17 | "status": 401, 18 | "error": "authentication required." 19 | } 20 | ``` 21 | 22 | - 成功失敗に関わらず、必ず `"result"` キーと `"status"` キーが存在する 23 | - `"result"` がfalseの場合は、`"error"` キーが存在し、理由文字列が入っている 24 | - `"status"` は対応するHTTPステータスコードが入っている 25 | - 30xでリダイレクトするときはレスポンスボディは存在しない 26 | - APIによっては、上記の基本的レスポンスに加えてAPIの結果を格納するキーが存在する 27 | 28 | ### # POST `/api/signup` 29 | 30 | アカウントを作成してセッションIDを返す 31 | セッションIDはCookieに入れる 32 | 33 | #### Request 34 | 35 | key | value | note 36 | --- | --- | --- 37 | user_account | string | 一意なユーザーアカウント文字列 38 | password | string | 39 | display_name | string | プレイリスト作者欄等に表示される名前 40 | 41 | user_account 42 | 43 | - 既存ユーザーと重複NG 44 | - 4文字以上36文字以内 45 | - 半角小文字、数字、アンダーバー、ハイフンのみ `[a-z0-9_\-]` 46 | 47 | password 48 | 49 | - 8文字以上64文字以内 50 | - 半角英数字、アンダーバー、ハイフンのみ `[a-zA-Z0-9_\-]` 51 | 52 | display_name 53 | 54 | - 2文字以上24文字以内 55 | 56 | ```json 57 | { 58 | "user_account": "isucon", 59 | "password": "isuconpass", 60 | "display_name": "イスコン" 61 | } 62 | ``` 63 | 64 | #### Response 65 | 66 | key | value | note 67 | --- | --- | --- 68 | 69 | ```json 70 | {} 71 | ``` 72 | 73 | HTTP Header 74 | 75 | ``` 76 | Set-Cookie: session_id=isuconsession 77 | ``` 78 | 79 | ### # POST `/api/login` 80 | 81 | ログインしてセッションIDを返す 82 | 83 | 以下の場合はログインに失敗する(HTTP status 401) 84 | - ユーザーが存在しない 85 | - ユーザーがBANされている 86 | - パスワードが間違っている 87 | 88 | #### Request 89 | 90 | key | value | note 91 | --- | --- | --- 92 | user_account | string | 93 | password | string | 94 | 95 | user_account 96 | 97 | - 4文字以上191文字以内 98 | - 半角小文字、数字、アンダーバー、ハイフンのみ `[a-z0-9_\-]` 99 | 100 | password 101 | 102 | - 8文字以上64文字以内 103 | - 半角英数字、アンダーバー、ハイフンのみ `[a-zA-Z0-9_\-]` 104 | 105 | ```json 106 | { 107 | "user_account": "isucon", 108 | "password": "isuconpass" 109 | } 110 | ``` 111 | 112 | #### 通常ユーザーのResponse 113 | 114 | key | value | note 115 | --- | --- | --- 116 | 117 | ```json 118 | {} 119 | ``` 120 | 121 | HTTP Header 122 | 123 | ``` 124 | Set-Cookie: session_id=isuconsession 125 | ``` 126 | 127 | #### BANユーザーの場合のResponse 128 | 129 | ```json 130 | { 131 | "result": false, 132 | "status": 200, 133 | "error": "BAN済みユーザーです" 134 | } 135 | ``` 136 | 137 | ### # POST `/api/logout` 138 | 139 | ログインセッションを破棄する 140 | 141 | #### Request 142 | 143 | key | value | note 144 | --- | --- | --- 145 | 146 | HTTP Header 147 | 148 | session_id 149 | 150 | - 空でも無効なセッションでもエラーを返さずOKとする 151 | 152 | ``` 153 | Cookie: session_id=isuconsession 154 | ``` 155 | 156 | #### Response 157 | 158 | key | value | note 159 | --- | --- | --- 160 | 161 | ```json 162 | {} 163 | ``` 164 | 165 | ### # GET `/api/recent_playlists` 166 | 167 | 全体のプレイリストを作成時刻が最新のものから100件返す 168 | 169 | - 公開playlistしか含まれない 170 | - 認証不要 171 | - BANされているユーザーが作成したplaylistは含まれない 172 | - 認証している場合 173 | - 自分が作成したplaylist、favoriteしたplaylistは is_favorited に正しい情報が入る 174 | - それ以外のplaylistでは is_favorited は常に false になる 175 | 176 | #### Request 177 | 178 | key | value | note 179 | --- | --- | --- 180 | 181 | #### Response 182 | 183 | key | value | note 184 | --- | --- | --- 185 | playlists | playlist_summary[] | プレイリストの概要の配列 186 | 187 | 188 | ```json 189 | { 190 | "playlists": [ 191 | { 192 | "ulid": "801G018N01064WKJE8000000000", 193 | "name": "イスコンのプレイリスト", 194 | "user_display_name": "イスコン", 195 | "song_count": 10, 196 | "favorite_count": 3, 197 | "is_favorited": false, 198 | "is_public": true 199 | }, 200 | { 201 | "ulid": "01G0B6EHG8T1YXJ9SRH02DHDRD", 202 | "name": "ツクエのプレイリスト", 203 | "user_display_name": "ツクエ", 204 | "song_count": 10, 205 | "favorite_count": 3, 206 | "is_favorited": true, 207 | "is_public": true 208 | }, 209 | ], 210 | } 211 | ``` 212 | 213 | ### # GET `/api/popular_playlists` 214 | 215 | 全体のプレイリストをFavoriteが多い順に100件返す 216 | 217 | - 公開playlistしか含まれない 218 | - 認証不要 219 | - BANされているユーザーが作成したplaylistは含まれない 220 | - 認証している場合 221 | - 自分が作成したplaylist、favoriteしたplaylistは is_favorited に正しい情報が入る 222 | - それ以外のplaylistでは is_favorited は常に false になる 223 | 224 | #### Request 225 | 226 | key | value | note 227 | --- | --- | --- 228 | 229 | #### Response 230 | 231 | key | value | note 232 | --- | --- | --- 233 | playlists | playlist_summary[] | プレイリストの概要の配列 234 | 235 | 236 | ```json 237 | { 238 | "playlists": [ 239 | { 240 | "ulid": "801G018N01064WKJE8000000000", 241 | "name": "イスコンのプレイリスト", 242 | "user_display_name": "イスコン", 243 | "song_count": 10, 244 | "favorite_count": 3, 245 | "is_favorited": false, 246 | "is_public": true 247 | }, 248 | { 249 | "ulid": "01G0B6EHG8T1YXJ9SRH02DHDRD", 250 | "name": "ツクエのプレイリスト", 251 | "user_display_name": "ツクエ", 252 | "song_count": 10, 253 | "favorite_count": 3, 254 | "is_favorited": true, 255 | "is_public": true 256 | }, 257 | ], 258 | } 259 | ``` 260 | 261 | ### # GET `/api/playlists` 262 | 263 | ログイン中のユーザーがトップページに表示させるプレイリストを返す 264 | 265 | - 自分が作成したプレイリスト一覧 作成日降順 最新100件まで 266 | - favしたプレイリスト一覧 fav日降順 最新100件まで 267 | - 自分以外が作成した、非公開プレイリストは含まれない 268 | - 自分が作成した非公開プレイリストは含まれる 269 | - 作成したユーザーがbanされている場合は含まれない 270 | 271 | この一覧にはそれぞれの曲の一覧は含まれない 272 | 273 | - 認証必須 274 | 275 | #### Request 276 | 277 | key | value | note 278 | --- | --- | --- 279 | 280 | HTTP Header 281 | 282 | ``` 283 | Cookie: session_id=isuconsession 284 | ``` 285 | 286 | #### Response 287 | 288 | key | value | note 289 | --- | --- | --- 290 | created_playlists | playlist_summary[] | 自分が作成したプレイリストの概要の配列 291 | favorited_playlists | playlist_summary[] | おきにいりプレイリストの概要の配列 292 | 293 | ```json 294 | { 295 | "created_playlists": [ 296 | { 297 | "ulid": "801G018N01064WKJE8000000000", 298 | "name": "イスコンのプレイリスト", 299 | "user_display_name": "イスコン", 300 | "song_count": 10, 301 | "favorite_count": 3, 302 | "is_favorited": false, 303 | "is_public": true 304 | }, 305 | ], 306 | "favorited_playlists": [ 307 | { 308 | "ulid": "01G0B6EHG8T1YXJ9SRH02DHDRD", 309 | "name": "ツクエのプレイリスト", 310 | "user_display_name": "ツクエ", 311 | "song_count": 10, 312 | "favorite_count": 3, 313 | "is_favorited": true, 314 | "is_public": true 315 | }, 316 | ], 317 | } 318 | ``` 319 | 320 | ### # POST `/api/playlist/add` 321 | 322 | 新しく空のプレイリストを作成する。is_public は常に false で作成される。 323 | 324 | - 認証必須 325 | 326 | #### Request 327 | 328 | ##### JSON Bodyとして渡す 329 | 330 | - 全て必須パラメータ 331 | 332 | key | value | note 333 | --- | --- | --- 334 | name | string | 作成するプレイリスト名
335 | 336 | name 337 | 338 | - 2文字以上191文字以内 339 | 340 | ```json 341 | { 342 | "name": "イスコンのプレイリスト" 343 | } 344 | ``` 345 | 346 | HTTP Header 347 | 348 | ``` 349 | Cookie: session_id=isuconsession 350 | ``` 351 | 352 | #### Response 353 | 354 | key | value | note 355 | --- | --- | --- 356 | playlist_ulid | string | 作成したプレイリストのulid 357 | 358 | ```json 359 | { 360 | "playlist_ulid": "801G018N01064WKJE8000000000", 361 | } 362 | ``` 363 | 364 | ### # GET `/api/playlist/{:playlist_ulid}` 365 | 366 | 指定したプレイリストの詳細を返す 367 | プレイリストの中の曲の一覧を含む 368 | 曲の一覧はプレイリストを更新する際に指定した順で返る 369 | 370 | - 認証なしでも叩けるが、is_favoritedは常にfalseになる 371 | - プレイリストを作成したユーザーがBANされている場合、HTTP status 404 372 | 373 | #### Request 374 | 375 | ##### URL parameterとして渡す 376 | 377 | key | value | note 378 | --- | --- | --- 379 | playlist_ulid | string | 380 | 381 | playlist_ulid 382 | 383 | - `[A-Z0-9]` ULIDとして有効なで構成されていること 384 | - 存在しないplaylist_ulidなら400エラー 385 | - 対象playlistがpublicではない かつ ログインセッションが有効でないなら404エラー 386 | - 対象playlistがpublicではない かつ ログインセッションが有効 かつ playlistの作成者が自分でなければ404エラー 387 | 388 | ``` 389 | "playlist_ulid": "801G018N01064WKJE8000000000" 390 | ``` 391 | 392 | HTTP Header 393 | 394 | session_id: 任意 395 | 396 | ``` 397 | Cookie: session_id=isuconsession 398 | ``` 399 | 400 | #### Response 401 | 402 | key | value | note 403 | --- | --- | --- 404 | playlist | playlist_detail | プレイリストの詳細 405 | 406 | ```json 407 | { 408 | "playlist": { 409 | "ulid": "801G018N01064WKJE8000000000", 410 | "name": "イスコンのプレイリスト", 411 | "user_display_name": "イスコン", 412 | "song_count": 10, 413 | "songs": [ 414 | { 415 | "ulid": "01G0180MS86400000000000000", 416 | "title": "椅子 on the floor", 417 | "artist": "ISU", 418 | "album": "ISU THE BEST", 419 | "track_number": 1, 420 | "is_public": true, 421 | }, 422 | ], 423 | "favorite_count": 3, 424 | "is_favorited": false, 425 | "is_public": true 426 | } 427 | } 428 | ``` 429 | 430 | ### # POST `/api/playlist/{:playlist_ulid}/update` 431 | 432 | プレイリストの内容を更新する 433 | プレイリストの作成者しか編集できない 434 | 435 | - 認証必須 436 | 437 | #### Request 438 | 439 | ##### URL parameterとして渡す 440 | 441 | - 必須パラメータ 442 | 443 | key | value | note 444 | --- | --- | --- 445 | playlist_ulid | string | 446 | 447 | playlist_ulid 448 | 449 | - `[A-Z0-9]` ULIDとして有効なで構成されていること 450 | - 存在しないplaylist_ulidなら404エラー 451 | - 対象playlistの作成者が自分でなければ404エラー 452 | 453 | ``` 454 | "playlist_ulid": "801G018N01064WKJE8000000000" 455 | ``` 456 | 457 | ##### JSON Bodyで渡す 458 | 459 | すべて必須(欠けている場合は400エラーとする) 460 | 461 | key | value | note 462 | --- | --- | --- 463 | name | string | プレイリスト名 464 | song_ulids | int[] | song_ulidの配列 465 | is_public | boolean | 公開ステータス 466 | 467 | name 468 | 469 | - すでに同じ名前のプレイリストを持っている場合は400エラー 470 | - 2文字以上24文字以内 471 | 472 | songs 473 | 474 | - songsが81曲以上の場合は400エラー 475 | - songsの中に重複する楽曲があれば400エラー 476 | 477 | ```json 478 | { 479 | "name": "イスコンプレイリストその2", 480 | "song_ulids": [ 481 | "01G04NHJ0JNVW66VZ0FEQ05S2X", 482 | "01G04NHJ0JQYJSJETTHDFM7BWE", 483 | "01G04NHJ0JSAZ6B18QM411JCXV" 484 | ], 485 | "is_public": false 486 | } 487 | ``` 488 | 489 | HTTP Header 490 | 491 | ``` 492 | Cookie: session_id=isuconsession 493 | ``` 494 | 495 | #### Response 496 | 497 | key | value | note 498 | --- | --- | --- 499 | playlist | playlist_detail | 更新後のplaylist 500 | 501 | ```json 502 | { 503 | "playlist": { 504 | "ulid": "801G018N01064WKJE8000000000", 505 | "name": "イスコンのプレイリスト", 506 | "user_display_name": "イスコン", 507 | "song_count": 10, 508 | "songs": [ 509 | { 510 | "ulid": "01G0180MS86400000000000000", 511 | "title": "椅子 on the floor", 512 | "artist": "ISU", 513 | "album": "ISU THE BEST", 514 | "track_number": 1, 515 | "is_public": true, 516 | } 517 | ], 518 | "favorite_count": 3, 519 | "is_favorited": false, 520 | "is_public": true 521 | } 522 | } 523 | ``` 524 | 525 | ### # POST `/api/playlist/{:playlist_ulid}/delete` 526 | 527 | プレイリストを削除する 528 | プレイリストの作成者しか削除できない 529 | 530 | - 認証必須 531 | 532 | #### Request 533 | 534 | ##### URL parameterとして渡す 535 | 536 | - 必須パラメータ 537 | 538 | key | value | note 539 | --- | --- | --- 540 | playlist_ulid | string | 541 | 542 | playlist_ulid 543 | 544 | - `[A-Z0-9]` ULIDとして有効なで構成されていること 545 | - 存在しないplaylist_ulidなら404エラー 546 | - 対象playlistの作成者が自分でなければ400エラー 547 | 548 | ``` 549 | "playlist_ulid": "801G018N01064WKJE8000000000" 550 | ``` 551 | 552 | HTTP Header 553 | 554 | session_id 555 | 556 | - 必須ではない 557 | 558 | ``` 559 | Cookie: session_id=isuconsession 560 | ``` 561 | 562 | #### Response 563 | 564 | key | value | note 565 | --- | --- | --- 566 | 567 | ```json 568 | {} 569 | ``` 570 | 571 | ### # POST `/api/playlist/{:playlist_ulid}/favorite` 572 | 573 | プレイリストのお気に入り登録状態を更新する 574 | 575 | - 認証必須 576 | - 自分以外が作成したプレイリストの場合、以下の時は失敗する(HTTP status 404) 577 | - プレイリストがprivate 578 | - プレイリストを作成したユーザーがbanされている 579 | 580 | #### Request 581 | 582 | ##### URL parameterとして渡す 583 | 584 | key | value | note 585 | --- | --- | --- 586 | playlist_ulid | string | 587 | 588 | playlist_ulid 589 | 590 | - `[A-Z0-9]` ULIDとして有効なで構成されていること 591 | - 存在しないplaylist_ulidなら404エラー 592 | - 対象playlistがpublicではない かつ ログインセッションが有効でないなら404エラー 593 | - 対象playlistがpublicではない かつ ログインセッションが有効 かつ playlistの作成者が自分でなければ404エラー 594 | - fav状態が変わらないリクエストはエラーにせず200を返す 595 | 596 | ``` 597 | "playlist_ulid": "801G018N01064WKJE8000000000" 598 | ``` 599 | 600 | ##### JSON Bodyとして渡す 601 | 602 | - 全て必須パラメータ 603 | 604 | key | value | note 605 | --- | --- | --- 606 | is_favorited | boolean | 更新後のお気に入り登録状態 607 | 608 | favorite 609 | 610 | - 更新前と更新後が変わらない場合も200 OKを返す 611 | 612 | ```json 613 | { 614 | "is_favorited": true 615 | } 616 | ``` 617 | 618 | HTTP Header 619 | 620 | ``` 621 | Cookie: session_id=isuconsession 622 | ``` 623 | 624 | #### Response 625 | 626 | key | value | note 627 | --- | --- | --- 628 | playlist | playlist_detail | 更新後のplaylist 629 | 630 | ```json 631 | { 632 | "playlist": { 633 | "ulid": "801G018N01064WKJE8000000000", 634 | "name": "イスコンのプレイリスト", 635 | "user_display_name": "イスコン", 636 | "song_count": 10, 637 | "songs": [ 638 | { 639 | "ulid": "01G0180MS86400000000000000", 640 | "title": "椅子 on the floor", 641 | "artist": "ISU", 642 | "album": "ISU THE BEST", 643 | "track_number": 1, 644 | "is_public": true, 645 | } 646 | ], 647 | "favorite_count": 3, 648 | "is_favorited": false, 649 | "is_public": true 650 | } 651 | } 652 | ``` 653 | 654 | ### # POST `/api/admin/user/ban` 655 | 656 | ユーザーのBAN状況を更新する 657 | 658 | - 管理者ユーザーの認証必須 659 | - BANされたユーザーは以下の状態になる 660 | - ログインに失敗する 661 | - 有効なログインセッションを持っていても、ログアウト以外の全てのAPIリクエストが失敗する 662 | - BANされたユーザーが作成したプレイリストは、他のユーザーに対してのAPIレスポンスに含まれなくなる 663 | 664 | このAPIの実行結果は3秒以内に他のAPIレスポンスに反映されている必要がある 665 | 666 | #### Request 667 | 668 | ##### JSON Bodyとして渡す 669 | 670 | - 全て必須パラメータ 671 | 672 | key | value | note 673 | --- | --- | --- 674 | user_account | string | 対象ユーザー 675 | is_ban | boolean | 指定ユーザーをBANに指定するか 676 | 677 | user_ulid 678 | 679 | - `[A-Z0-9]` ULIDとして有効なで構成されていること 680 | - 存在しないuser_ulidなら400エラー 681 | 682 | ```json 683 | { 684 | "user_account": "isucon", 685 | "is_ban": true 686 | } 687 | ``` 688 | 689 | HTTP Header 690 | 691 | ``` 692 | Cookie: session_id=rootusersession 693 | ``` 694 | 695 | #### Response 696 | 697 | key | value | note 698 | --- | --- | --- 699 | (なし) | user | 更新後のuser情報 700 | 701 | ```json 702 | { 703 | "user_account": "isucon", 704 | "display_name": "イスコン", 705 | "is_ban": true 706 | } 707 | ``` 708 | 709 | #### 管理者ユーザーでない場合のエラーレスポンス 710 | 711 | ```json 712 | { 713 | "result": false, 714 | "status": 403, 715 | "error": "not admin user" 716 | } 717 | ``` 718 | 719 | ## 型定義 720 | 721 | date は ISO8601 フォーマットの文字列とする 722 | 723 | ### # user 724 | 725 | key | value | note 726 | --- | --- | --- 727 | user_account | string | 728 | display_name | string | 表示名、プレイリスト作者などで使われる 729 | is_ban | boolean | BANされているか 730 | created_at | date | ユーザーを作成した日時 731 | 732 | ```json 733 | { 734 | "user_account": "isucon", 735 | "display_name": "イスコン", 736 | "is_ban": false, 737 | "created_at": "2012-04-23T18:25:43.511Z", 738 | } 739 | ``` 740 | 741 | ### # song 742 | 743 | key | value | note 744 | --- | --- | --- 745 | ulid | string | 曲固有の識別子 746 | title | string | 曲名 747 | artist | string | アーティスト名 id参照 748 | album | string | アルバム名 749 | track_number | int | album内での曲順 1 based 750 | is_public | boolean | 公開中かどうか 751 | 752 | ```json 753 | { 754 | "ulid": "01G0180MS86400000000000000", 755 | "title": "椅子 on the floor", 756 | "artist": "ISU", 757 | "album": "ISU THE BEST", 758 | "track_number": 1, 759 | "is_public": true, 760 | } 761 | ``` 762 | 763 | ### # playlist_summary 764 | 765 | 多数のプレイリストの一覧表示に利用する情報 766 | 767 | key | value | note 768 | --- | --- | --- 769 | ulid | string | プレイリストの固有識別子 ULID 770 | name | string | 771 | user_display_name | string | 作成者のdisplay name 772 | user_account | string | 作成者のaccount 773 | song_count | int | プレイリスト内の曲数 774 | favorite_count | int | プレイリストがお気に入りされた回数 775 | is_favorited | boolean | 自分がプレイリストをお気に入り済みか 776 | is_public | boolean | | 公開中かどうか 777 | created_at | date | プレイリストを作成した日時 778 | updated_at | date | プレイリストを最終更新した日時 779 | 780 | ```json 781 | { 782 | "ulid": "801G018N01064WKJE8000000000", 783 | "name": "イスコンのプレイリスト", 784 | "user_display_name": "イスコン", 785 | "song_count": 10, 786 | "favorite_count": 3, 787 | "is_favorited": false, 788 | "is_public": true, 789 | "created_at": "2012-04-23T18:25:43.511Z", 790 | "updated_at":"2012-04-23T18:25:43.511Z" 791 | } 792 | ``` 793 | 794 | ### # playlist_detail 795 | 796 | 個々のプレイリストの内容 797 | 798 | key | value | note 799 | --- | --- | --- 800 | ulid | string | プレイリストの固有識別子 ULID 801 | name | string | 802 | user_display_name | string | 作成者のdisplay name 803 | song_count | int | プレイリスト内の曲数 804 | songs | song[] | プレイリスト内の曲一覧 805 | favorite_count | int | プレイリストがお気に入りされた回数 806 | is_favorited | boolean | 自分がプレイリストをお気に入り済みか 807 | is_public | boolean | 公開中かどうか 808 | created_at | date | プレイリストを作成した日時 809 | updated_at | date | プレイリストを最終更新した日時 810 | 811 | ```json 812 | { 813 | "ulid": "801G018N01064WKJE8000000000", 814 | "name": "イスコンのプレイリスト", 815 | "user_display_name": "イスコン", 816 | "song_count": 10, 817 | "songs": [ 818 | "((songの配列))" 819 | ], 820 | "favorite_count": 3, 821 | "is_favorited": false, 822 | "is_public": true, 823 | "created_at": "2012-04-23T18:25:43.511Z", 824 | "updated_at":"2012-04-23T18:25:43.511Z" 825 | } 826 | ``` 827 | -------------------------------------------------------------------------------- /bench/scenario_valiadtion.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "time" 8 | 9 | "github.com/isucon/isucandar" 10 | "github.com/samber/lo" 11 | ) 12 | 13 | // 整合性検証シナリオ 14 | // 自分で作ったplaylistを直後に削除したりするので、並列で実行するとfavした他人のplaylistが削除されて壊れる可能性がある 15 | // 負荷テスト中には実行してはいけない 16 | func (s *Scenario) ValidationScenario(ctx context.Context, step *isucandar.BenchmarkStep) error { 17 | report := timeReporter("validation") 18 | defer report() 19 | 20 | ContestantLogger.Println("整合性チェックを開始します") 21 | defer ContestantLogger.Printf("整合性チェックを終了します") 22 | 23 | ag, _ := s.Option.NewAgent(false) 24 | { 25 | // GET / 26 | res, err := GetRootAction(ctx, ag) 27 | v := ValidateResponse("トップページ", step, res, err, WithStatusCode(200, 304)) 28 | if !v.IsEmpty() { 29 | return v 30 | } 31 | } 32 | 33 | var user *User 34 | { 35 | // ユーザー作成 36 | u, res, err := SignupAction(ctx, ag) 37 | user = u 38 | v := ValidateResponse("新規ユーザー登録", 39 | step, res, err, 40 | WithStatusCode(200), 41 | WithSuccessResponse[ResponseAPIBase](), 42 | ) 43 | if !v.IsEmpty() { 44 | return v 45 | } 46 | } 47 | { 48 | // ログイン 49 | res, err := LoginAction(ctx, user, ag) 50 | v := ValidateResponse("ログイン", 51 | step, res, err, 52 | WithStatusCode(200), 53 | WithSuccessResponse[ResponseAPIBase](), 54 | ) 55 | if !v.IsEmpty() { 56 | return v 57 | } 58 | } 59 | var topPlaylist Playlist 60 | { 61 | // 人気プレイリスト一覧 62 | res, err := GetPopularPlaylistsAction(ctx, ag) 63 | v := ValidateResponse("人気プレイリスト一覧", 64 | step, res, err, 65 | WithStatusCode(200), 66 | WithCacheControlPrivate(), 67 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 68 | if len(r.Playlists) > 100 { 69 | return fmt.Errorf("人気プレイリスト一覧が100より多くあります %d", len(r.Playlists)) 70 | } 71 | if len(r.Playlists) < 50 { 72 | return fmt.Errorf("人気プレイリスト一覧が少なすぎます %d", len(r.Playlists)) 73 | } 74 | topPlaylist = r.Playlists[0] 75 | var prevFavs int 76 | for _, p := range r.Playlists { 77 | if prevFavs == 0 { 78 | prevFavs = p.FavoriteCount 79 | } 80 | if prevFavs < p.FavoriteCount { 81 | return fmt.Errorf("人気プレイリスト一覧のfav数が降順になっていません %d < %d", prevFavs, p.FavoriteCount) 82 | } 83 | prevFavs = p.FavoriteCount 84 | if p.IsPublic == false { 85 | return fmt.Errorf("人気プレイリスト一覧に非公開プレイリストが含まれています") 86 | } 87 | } 88 | return nil 89 | }), 90 | ) 91 | if !v.IsEmpty() { 92 | return v 93 | } 94 | } 95 | { 96 | // favをつける 97 | topPlaylist.IsFavorited = true 98 | res, err := FavoritePlaylistAction(ctx, &topPlaylist, ag) 99 | v := ValidateResponse("人気のプレイリストにfavする", 100 | step, res, err, 101 | WithStatusCode(200), 102 | WithCacheControlPrivate(), 103 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 104 | if !r.Playlist.IsFavorited { 105 | return fmt.Errorf("プレイリストのfavがついていません") 106 | } 107 | if r.Playlist.FavoriteCount == 0 { 108 | return fmt.Errorf("プレイリストのfav数が0です") 109 | } 110 | return nil 111 | }), 112 | ) 113 | if !v.IsEmpty() { 114 | return v 115 | } 116 | } 117 | { 118 | // 人気プレイリスト一覧 119 | res, err := GetPopularPlaylistsAction(ctx, ag) 120 | v := ValidateResponse("人気プレイリスト一覧 favがついている", 121 | step, res, err, 122 | WithStatusCode(200), 123 | WithCacheControlPrivate(), 124 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 125 | if len(r.Playlists) > 100 { 126 | return fmt.Errorf("人気プレイリスト一覧が100より多くあります %d", len(r.Playlists)) 127 | } 128 | if len(r.Playlists) < 50 { 129 | return fmt.Errorf("人気プレイリスト一覧が少なすぎます %d", len(r.Playlists)) 130 | } 131 | var found bool 132 | for _, p := range r.Playlists { 133 | if p.FavoriteCount == 0 { 134 | return fmt.Errorf("人気プレイリストのfav数が0です") 135 | } 136 | if topPlaylist.ULID == p.ULID { 137 | if !p.IsFavorited { 138 | return fmt.Errorf("favしたプレイリストにfavがついていません") 139 | } 140 | found = true 141 | break 142 | } 143 | } 144 | if !found { 145 | return fmt.Errorf("favしたプレイリストが人気プレイリスト一覧にありません") 146 | } 147 | return nil 148 | }), 149 | ) 150 | if !v.IsEmpty() { 151 | return v 152 | } 153 | } 154 | { 155 | // プレイリスト一覧 作成済み0, fav済み1 156 | res, err := GetPlaylistsAction(ctx, ag) 157 | v := ValidateResponse("ログイン後自分のプレイリスト一覧を取得", 158 | step, res, err, 159 | WithStatusCode(200), 160 | WithCacheControlPrivate(), 161 | WithSuccessResponse(func(r ResponseAPIGetPlaylists) error { 162 | if len(r.CreatedPlaylists) != 0 { 163 | return fmt.Errorf("作成済みプレイリストは0件のはずですが、%d件あります", len(r.CreatedPlaylists)) 164 | } 165 | if len(r.FavoritedPlaylists) != 1 { 166 | return fmt.Errorf("fav済みプレイリストは1件のはずですが、%d件あります", len(r.FavoritedPlaylists)) 167 | } 168 | return nil 169 | }), 170 | ) 171 | if !v.IsEmpty() { 172 | return v 173 | } 174 | } 175 | var playlistULID, playlistName string 176 | { 177 | // 新規プレイリスト作成 178 | playlistName = DisplayName() 179 | res, err := AddPlayistAction(ctx, playlistName, ag) 180 | v := ValidateResponse("新規プレイリスト作成", 181 | step, res, err, 182 | WithStatusCode(200), 183 | WithCacheControlPrivate(), 184 | WithSuccessResponse(func(r ResponseAPIAddPlaylist) error { 185 | if r.PlaylistULID == "" { 186 | return fmt.Errorf("プレイリストのULIDが空です") 187 | } 188 | playlistULID = r.PlaylistULID 189 | return nil 190 | }), 191 | ) 192 | if !v.IsEmpty() { 193 | return v 194 | } 195 | } 196 | { 197 | // プレイリスト一覧 作成済み1, fav済み1 198 | res, err := GetPlaylistsAction(ctx, ag) 199 | v := ValidateResponse("自分のプレイリスト一覧に作成済みプレイリストが含まれている", 200 | step, res, err, 201 | WithStatusCode(200), 202 | WithCacheControlPrivate(), 203 | WithSuccessResponse(func(r ResponseAPIGetPlaylists) error { 204 | if len(r.CreatedPlaylists) != 1 { 205 | return fmt.Errorf("作成済みプレイリストは1件のはずですが、%d件あります", len(r.CreatedPlaylists)) 206 | } 207 | if r.CreatedPlaylists[0].ULID != playlistULID { 208 | return fmt.Errorf("作成されたプレイリストのULIDが一致しません") 209 | } 210 | if len(r.FavoritedPlaylists) != 1 { 211 | return fmt.Errorf("fav済みプレイリストは1件のはずですが、%d件あります", len(r.FavoritedPlaylists)) 212 | } 213 | return nil 214 | }), 215 | ) 216 | if !v.IsEmpty() { 217 | return v 218 | } 219 | } 220 | var playlist Playlist 221 | { 222 | // プレイリスト詳細 作成直後なので空 223 | res, err := GetPlaylistAction(ctx, playlistULID, ag) 224 | v := ValidateResponse("作成したプレイリスト詳細", 225 | step, res, err, 226 | WithStatusCode(200), 227 | WithCacheControlPrivate(), 228 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 229 | if r.Playlist.ULID != playlistULID { 230 | return fmt.Errorf("プレイリストのULIDが一致しません") 231 | } 232 | if r.Playlist.SongCount != 0 { 233 | return fmt.Errorf("プレイリストの曲数が0ではありません") 234 | } 235 | if r.Playlist.UserDisplayName != user.DisplayName { 236 | return fmt.Errorf( 237 | "プレイリストのdisplay_name %s が自分自身のdisplay_name %s と一致しません", 238 | r.Playlist.UserDisplayName, 239 | user.DisplayName, 240 | ) 241 | } 242 | if r.Playlist.Name != playlistName { 243 | return fmt.Errorf("プレイリストの名前 %s が作成したプレイリストの名前 %s と一致しません", r.Playlist.Name, playlistName) 244 | } 245 | playlist = r.Playlist 246 | return nil 247 | }), 248 | ) 249 | if !v.IsEmpty() { 250 | return v 251 | } 252 | } 253 | addSongs := lo.Samples(s.Songs, rand.Intn(4)) 254 | { 255 | // プレイリスト更新、n曲追加 公開にする 256 | playlist.Songs = append(playlist.Songs, addSongs...) 257 | playlist.IsPublic = true 258 | res, err := UpdatePlayistAction(ctx, &playlist, ag) 259 | v := ValidateResponse("プレイリスト更新 曲を追加して公開にする", 260 | step, res, err, 261 | WithStatusCode(200), 262 | WithCacheControlPrivate(), 263 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 264 | if r.Playlist.ULID != playlistULID { 265 | return fmt.Errorf("曲を追加したプレイリストのULIDとレスポンスが一致しません") 266 | } 267 | if r.Playlist.SongCount != len(addSongs) { 268 | return fmt.Errorf("プレイリストの曲数 %d が追加した曲数 %d と一致しません", r.Playlist.SongCount, len(addSongs)) 269 | } 270 | expectd := playlist.Songs.ULIDs() 271 | for i, ret := range r.Playlist.Songs.ULIDs() { 272 | if ret != expectd[i] { 273 | return fmt.Errorf("%d番目に追加した曲のUILD %s がレスポンス %s と一致しません", i, expectd[i], ret) 274 | } 275 | } 276 | return nil 277 | }), 278 | ) 279 | if !v.IsEmpty() { 280 | return v 281 | } 282 | } 283 | { 284 | // プレイリスト詳細 n曲含む 285 | res, err := GetPlaylistAction(ctx, playlistULID, ag) 286 | v := ValidateResponse("プレイリスト詳細に追加した曲が含まれている", 287 | step, res, err, 288 | WithStatusCode(200), 289 | WithCacheControlPrivate(), 290 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 291 | if r.Playlist.ULID != playlistULID { 292 | return fmt.Errorf("リクエストしたプレイリストのULIDとレスポンスが一致しません") 293 | } 294 | if !r.Playlist.IsPublic { 295 | return fmt.Errorf("プレイリストが公開されていません") 296 | } 297 | if r.Playlist.SongCount != len(addSongs) { 298 | return fmt.Errorf("プレイリストの曲数 %d が追加した曲数 %d と一致しません", r.Playlist.SongCount, len(addSongs)) 299 | } 300 | expectd := playlist.Songs.ULIDs() 301 | for i, ret := range r.Playlist.Songs.ULIDs() { 302 | if ret != expectd[i] { 303 | return fmt.Errorf("%d番目に追加した曲のUILD %s がレスポンス %s と一致しません", i, expectd[i], ret) 304 | } 305 | } 306 | return nil 307 | }), 308 | ) 309 | if !v.IsEmpty() { 310 | return v 311 | } 312 | } 313 | { 314 | // favをつける 315 | playlist.IsFavorited = true 316 | res, err := FavoritePlaylistAction(ctx, &playlist, ag) 317 | v := ValidateResponse("プレイリストにfavする", 318 | step, res, err, 319 | WithStatusCode(200), 320 | WithCacheControlPrivate(), 321 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 322 | if !r.Playlist.IsFavorited { 323 | return fmt.Errorf("プレイリストのfavがついていません") 324 | } 325 | if r.Playlist.FavoriteCount == 0 { 326 | return fmt.Errorf("プレイリストのfav数が0です") 327 | } 328 | return nil 329 | }), 330 | ) 331 | if !v.IsEmpty() { 332 | return v 333 | } 334 | } 335 | { 336 | // 最新プレイリスト一覧 今投稿したプレイリストが含まれている & favがついている 337 | // 自分がfavしていないプレイリストは is_favorited が false 338 | res, err := GetRecentPlaylistsAction(ctx, ag) 339 | v := ValidateResponse("最新プレイリスト一覧に公開した自分のプレイリストが含まれている", 340 | step, res, err, 341 | WithStatusCode(200), 342 | WithCacheControlPrivate(), 343 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 344 | var found bool 345 | var prevCreatedAt time.Time 346 | if len(r.Playlists) != 100 { 347 | return fmt.Errorf("playlists must be 100") 348 | } 349 | for _, p := range r.Playlists { 350 | // 秒で切りそろえてから比較(サブ秒での逆転は許すため) 351 | createdAt := p.CreatedAt.Truncate(time.Second) 352 | if prevCreatedAt.IsZero() { 353 | prevCreatedAt = createdAt 354 | } else if createdAt.After(prevCreatedAt) { 355 | return fmt.Errorf("最新プレイリスト一覧の作成日時が降順になっていません %s < %s", p.CreatedAt, prevCreatedAt) 356 | } 357 | prevCreatedAt = createdAt 358 | if p.IsPublic == false { 359 | return fmt.Errorf("最新プレイリスト一覧に非公開プレイリストが含まれています") 360 | } 361 | if p.IsFavorited { 362 | // この時点では2件しかfavしていないはず 363 | if p.ULID != playlistULID && p.ULID != topPlaylist.ULID { 364 | return fmt.Errorf("favしていないはずのプレイリストがfavされています") 365 | } 366 | } 367 | if p.ULID == playlistULID { 368 | found = true 369 | if p.SongCount != len(addSongs) { 370 | return fmt.Errorf("プレイリスト %s には %d曲含まれているはずですが%d曲です", p.ULID, len(addSongs), p.SongCount) 371 | } 372 | if p.FavoriteCount == 0 { 373 | return fmt.Errorf("プレイリスト %s はfavされているはずですがfav数が0です", p.ULID) 374 | } 375 | if p.IsFavorited != true { 376 | return fmt.Errorf("プレイリスト %s はfavされているはずですがfavされていません", p.ULID) 377 | } 378 | } 379 | } 380 | if !found { 381 | return fmt.Errorf("作成したプレイリスト %s が最新プレイリスト一覧に含まれていません", playlistULID) 382 | } 383 | return nil 384 | }), 385 | ) 386 | if !v.IsEmpty() { 387 | return v 388 | } 389 | } 390 | { 391 | // プレイリスト一覧 作成済み1, fav済み2 392 | res, err := GetPlaylistsAction(ctx, ag) 393 | v := ValidateResponse("プレイリスト詳細に作成済みとfav済みがある", 394 | step, res, err, 395 | WithStatusCode(200), 396 | WithCacheControlPrivate(), 397 | WithSuccessResponse(func(r ResponseAPIGetPlaylists) error { 398 | if len(r.CreatedPlaylists) != 1 { 399 | return fmt.Errorf("作成済みプレイリストは1つであるはずですが %d 件です", len(r.CreatedPlaylists)) 400 | } 401 | if r.CreatedPlaylists[0].ULID != playlistULID { 402 | return fmt.Errorf("作成済みプレイリストのULIDが一致しません %s != %s", r.CreatedPlaylists[0].ULID, playlistULID) 403 | } 404 | if len(r.FavoritedPlaylists) != 2 { 405 | return fmt.Errorf("fav済みプレイリストは2つであるはずですが %d 件です", len(r.FavoritedPlaylists)) 406 | } 407 | var found bool 408 | for _, p := range r.FavoritedPlaylists { 409 | if p.ULID == playlistULID { 410 | found = true 411 | break 412 | } 413 | } 414 | if !found { 415 | return fmt.Errorf("favしたプレイリストがありません") 416 | } 417 | return nil 418 | }), 419 | ) 420 | if !v.IsEmpty() { 421 | return v 422 | } 423 | } 424 | { 425 | // プレイリスト詳細 fav済み 426 | res, err := GetPlaylistAction(ctx, playlistULID, ag) 427 | v := ValidateResponse("プレイリスト詳細でfav済みになっている", 428 | step, res, err, 429 | WithStatusCode(200), 430 | WithCacheControlPrivate(), 431 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 432 | if r.Playlist.ULID != playlistULID { 433 | return fmt.Errorf("プレイリストのULIDが一致しません %s != %s", r.Playlist.ULID, playlistULID) 434 | } 435 | if !r.Playlist.IsFavorited { 436 | return fmt.Errorf("プレイリスト %s がfavされているはずですがfavされていません", r.Playlist.ULID) 437 | } 438 | if r.Playlist.FavoriteCount == 0 { 439 | return fmt.Errorf("プレイリスト %s はfavされているはずですが0件です", r.Playlist.ULID) 440 | } 441 | return nil 442 | }), 443 | ) 444 | if !v.IsEmpty() { 445 | return v 446 | } 447 | } 448 | { 449 | // favをはずす 450 | playlist.IsFavorited = false 451 | res, err := FavoritePlaylistAction(ctx, &playlist, ag) 452 | v := ValidateResponse("プレイリストのfavをはずす", 453 | step, res, err, 454 | WithStatusCode(200), 455 | WithCacheControlPrivate(), 456 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 457 | if r.Playlist.ULID != playlistULID { 458 | return fmt.Errorf("プレイリストのULIDが一致しません %s != %s", r.Playlist.ULID, playlistULID) 459 | } 460 | if r.Playlist.IsFavorited { 461 | return fmt.Errorf("プレイリスト %s はfavされていないはずですがfavされています", r.Playlist.ULID) 462 | } 463 | return nil 464 | }), 465 | ) 466 | if !v.IsEmpty() { 467 | return v 468 | } 469 | } 470 | { 471 | // プレイリスト詳細 favなし 472 | res, err := GetPlaylistAction(ctx, playlistULID, ag) 473 | v := ValidateResponse("プレイリスト詳細でfavなしになっている", 474 | step, res, err, 475 | WithStatusCode(200), 476 | WithCacheControlPrivate(), 477 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 478 | if r.Playlist.ULID != playlistULID { 479 | return fmt.Errorf("プレイリストのULIDが一致しません %s != %s", r.Playlist.ULID, playlistULID) 480 | } 481 | if r.Playlist.IsFavorited { 482 | return fmt.Errorf("プレイリスト %s はfavされていないはずですがfavされています", r.Playlist.ULID) 483 | } 484 | return nil 485 | }), 486 | ) 487 | if !v.IsEmpty() { 488 | return v 489 | } 490 | } 491 | { 492 | // プレイリスト一覧 作成済みにはあるがfav済みにはない 493 | res, err := GetPlaylistsAction(ctx, ag) 494 | v := ValidateResponse("プレイリスト一覧 作成済みがあるがfav済みはない", 495 | step, res, err, 496 | WithStatusCode(200), 497 | WithCacheControlPrivate(), 498 | WithSuccessResponse(func(r ResponseAPIGetPlaylists) error { 499 | var found bool 500 | for _, p := range r.CreatedPlaylists { 501 | if p.ULID == playlist.ULID { 502 | found = true 503 | break 504 | } 505 | } 506 | if !found { 507 | return fmt.Errorf("プレイリスト %s が作成済みプレイリスト一覧にありません", playlist.ULID) 508 | } 509 | for _, p := range r.FavoritedPlaylists { 510 | if p.ULID == playlist.ULID { 511 | return fmt.Errorf("プレイリスト %s がfav済みプレイリスト一覧にないはずなのにあります", playlist.ULID) 512 | } 513 | } 514 | return nil 515 | }), 516 | ) 517 | if !v.IsEmpty() { 518 | return v 519 | } 520 | } 521 | { 522 | // 作成したプレイリスト削除 523 | res, err := DeletePlaylistAction(ctx, &playlist, ag) 524 | v := ValidateResponse("プレイリスト削除", 525 | step, res, err, 526 | WithStatusCode(200), 527 | WithCacheControlPrivate(), 528 | WithSuccessResponse[ResponseAPIBase](), 529 | ) 530 | if !v.IsEmpty() { 531 | return v 532 | } 533 | } 534 | { 535 | // プレイリスト詳細 404 536 | res, err := GetPlaylistAction(ctx, playlistULID, ag) 537 | v := ValidateResponse("削除されたプレイリストは404になる", 538 | step, res, err, 539 | WithStatusCode(404), 540 | WithCacheControlPrivate(), 541 | ) 542 | if !v.IsEmpty() { 543 | return v 544 | } 545 | } 546 | { 547 | // プレイリスト一覧 作成済みとfav済みに削除したものが含まれていない 548 | res, err := GetPlaylistsAction(ctx, ag) 549 | v := ValidateResponse("プレイリスト一覧に削除したプレイリストが含まれていない", 550 | step, res, err, 551 | WithStatusCode(200), 552 | WithCacheControlPrivate(), 553 | WithSuccessResponse(func(r ResponseAPIGetPlaylists) error { 554 | for _, p := range r.CreatedPlaylists { 555 | if p.ULID == playlist.ULID { 556 | return fmt.Errorf("削除したプレイリスト %s が作成済みプレイリスト一覧にあります", playlist.ULID) 557 | } 558 | } 559 | for _, p := range r.FavoritedPlaylists { 560 | if p.ULID == playlist.ULID { 561 | return fmt.Errorf("削除したプレイリスト %s がfav済みプレイリスト一覧にあります", playlist.ULID) 562 | } 563 | } 564 | return nil 565 | }), 566 | ) 567 | if !v.IsEmpty() { 568 | return v 569 | } 570 | } 571 | var othersPlaylist Playlist 572 | { 573 | // 最新プレイリスト一覧 今削除したプレイリストが含まれていない 574 | res, err := GetRecentPlaylistsAction(ctx, ag) 575 | v := ValidateResponse("最新プレイリスト一覧に削除済みプレイリストが含まれていない", 576 | step, res, err, 577 | WithStatusCode(200), 578 | WithCacheControlPrivate(), 579 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 580 | for _, p := range r.Playlists { 581 | if p.ULID == playlistULID { 582 | return fmt.Errorf("削除したプレイリスト %s が最新プレイリスト一覧にあります", playlist.ULID) 583 | } 584 | if p.UserDisplayName != user.DisplayName { 585 | p := p 586 | othersPlaylist = p // 他人のをfavするために使う 587 | } 588 | } 589 | return nil 590 | }), 591 | ) 592 | if !v.IsEmpty() { 593 | return v 594 | } 595 | } 596 | { 597 | // 他人のにfavをつける 598 | othersPlaylist.IsFavorited = true 599 | res, err := FavoritePlaylistAction(ctx, &othersPlaylist, ag) 600 | v := ValidateResponse("自分以外のプレイリストをfavできる", 601 | step, res, err, 602 | WithStatusCode(200), 603 | WithCacheControlPrivate(), 604 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 605 | if r.Playlist.ULID != othersPlaylist.ULID { 606 | return fmt.Errorf("favしたプレイリスト %s がレスポンスと一致しません", othersPlaylist.ULID) 607 | } 608 | if !r.Playlist.IsFavorited { 609 | return fmt.Errorf("favしたプレイリスト %s がfav済みになっていません", othersPlaylist.ULID) 610 | } 611 | if r.Playlist.FavoriteCount == 0 { 612 | return fmt.Errorf("favしたプレイリスト %s のfav数が0です", othersPlaylist.ULID) 613 | } 614 | return nil 615 | }), 616 | ) 617 | if !v.IsEmpty() { 618 | return v 619 | } 620 | } 621 | { 622 | // ログアウト 623 | res, err := LogoutAction(ctx, user, ag) 624 | v := ValidateResponse("ログアウトできる", 625 | step, res, err, 626 | WithStatusCode(200), 627 | WithSuccessResponse[ResponseAPIBase](), 628 | ) 629 | if !v.IsEmpty() { 630 | return v 631 | } 632 | } 633 | // ログアウト後1秒待ってみる 634 | time.Sleep(time.Second) 635 | { 636 | // プレイリスト一覧 ログアウト後には見えない 637 | res, err := GetPlaylistsAction(ctx, ag) 638 | v := ValidateResponse("非ログイン状態では自分のプレイリストは見えない", 639 | step, res, err, 640 | WithStatusCode(401), 641 | ) 642 | if !v.IsEmpty() { 643 | return v 644 | } 645 | } 646 | { 647 | // プレイリスト詳細 ログアウトしているのでfavはついていない 648 | res, err := GetPlaylistAction(ctx, othersPlaylist.ULID, ag) 649 | v := ValidateResponse("非ログイン状態でプレイリスト詳細を取得した場合 favは常にfalse", 650 | step, res, err, 651 | WithStatusCode(200, 304), 652 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 653 | if r.Playlist.ULID != othersPlaylist.ULID { 654 | return fmt.Errorf("リクエストしたプレイリスト %s がレスポンスと一致しません", othersPlaylist.ULID) 655 | } 656 | if r.Playlist.IsFavorited { 657 | return fmt.Errorf("非ログイン状態でリクエストしたプレイリスト %s がfav済みになっています", othersPlaylist.ULID) 658 | } 659 | return nil 660 | }), 661 | ) 662 | if !v.IsEmpty() { 663 | return v 664 | } 665 | } 666 | 667 | // これからbanされるuser 668 | var banUser *User 669 | { 670 | // ユーザー作成 671 | u, res, err := SignupAction(ctx, ag) 672 | banUser = u 673 | defer res.Body.Close() 674 | v := ValidateResponse("ユーザー新規登録", 675 | step, res, err, 676 | WithStatusCode(200), 677 | WithSuccessResponse[ResponseAPIBase](), 678 | ) 679 | if !v.IsEmpty() { 680 | return v 681 | } 682 | } 683 | banUserAg, _ := banUser.GetAgent(s.Option) 684 | { 685 | res, err := LoginAction(ctx, banUser, banUserAg) 686 | v := ValidateResponse("ユーザーログイン", 687 | step, res, err, 688 | WithStatusCode(200), 689 | ) 690 | if !v.IsEmpty() { 691 | return v 692 | } 693 | } 694 | time.Sleep(100 * time.Millisecond) 695 | var banUserPlaylist Playlist 696 | { 697 | // banされるuserの新規プレイリスト作成 698 | playlistName = DisplayName() 699 | res, err := AddPlayistAction(ctx, playlistName, banUserAg) 700 | v := ValidateResponse("新規プレイリスト作成", 701 | step, res, err, 702 | WithStatusCode(200), 703 | WithCacheControlPrivate(), 704 | WithSuccessResponse(func(r ResponseAPIAddPlaylist) error { 705 | banUserPlaylist = Playlist{ 706 | Name: playlistName, 707 | ULID: r.PlaylistULID, 708 | Songs: lo.Samples(s.Songs, rand.Intn(40)), 709 | IsPublic: true, 710 | } 711 | return nil 712 | }), 713 | ) 714 | if !v.IsEmpty() { 715 | return v 716 | } 717 | } 718 | { 719 | res, err := UpdatePlayistAction(ctx, &banUserPlaylist, banUserAg) 720 | v := ValidateResponse("プレイリストを更新", 721 | step, res, err, 722 | WithStatusCode(200), 723 | WithCacheControlPrivate(), 724 | WithSuccessResponse(func(r ResponseAPIGetPlaylist) error { 725 | return nil 726 | }), 727 | ) 728 | if !v.IsEmpty() { 729 | return v 730 | } 731 | } 732 | 733 | adminAg, _ := s.AdminUser.GetAgent(s.Option) 734 | { 735 | admin := s.AdminUser 736 | res, err := LoginAction(ctx, admin, adminAg) 737 | v := ValidateResponse("adminがログイン", 738 | step, res, err, 739 | WithStatusCode(200), 740 | ) 741 | if !v.IsEmpty() { 742 | return v 743 | } 744 | } 745 | { 746 | res, err := GetPlaylistAction(ctx, banUserPlaylist.ULID, adminAg) 747 | v := ValidateResponse("プレイリスト詳細がある", 748 | step, res, err, 749 | WithStatusCode(200), 750 | WithCacheControlPrivate(), 751 | ) 752 | if !v.IsEmpty() { 753 | return v 754 | } 755 | } 756 | { 757 | res, err := AdminBanAction(ctx, banUser, true, adminAg) 758 | v := ValidateResponse("adminがユーザーをban", 759 | step, res, err, 760 | WithStatusCode(200), 761 | WithCacheControlPrivate(), 762 | WithSuccessResponse(func(r ResponseAdminBan) error { 763 | if r.UserAccount != banUser.Account { 764 | return fmt.Errorf("banしたユーザー %s がレスポンス %s と一致しません", banUser.Account, r.UserAccount) 765 | } 766 | if !r.IsBan { 767 | return fmt.Errorf("ユーザー %s がbanされていません", banUser.Account) 768 | } 769 | return nil 770 | }), 771 | ) 772 | if !v.IsEmpty() { 773 | return v 774 | } 775 | } 776 | // ban操作のあと3秒は猶予がある 777 | time.Sleep(3 * time.Second) 778 | { 779 | res, err := GetPlaylistsAction(ctx, banUserAg) 780 | v := ValidateResponse("banされたユーザーは自分のプレイリストを取得できない", 781 | step, res, err, 782 | WithStatusCode(401), 783 | ) 784 | if !v.IsEmpty() { 785 | return v 786 | } 787 | } 788 | { 789 | res, err := GetPlaylistAction(ctx, banUserPlaylist.ULID, adminAg) 790 | v := ValidateResponse("banされたユーザーのプレイリスト詳細が404になる", 791 | step, res, err, 792 | WithStatusCode(404), 793 | ) 794 | if !v.IsEmpty() { 795 | return v 796 | } 797 | } 798 | { 799 | res, err := GetRecentPlaylistsAction(ctx, adminAg) 800 | v := ValidateResponse("最新プレイリスト一覧にbanされたユーザーのプレイリストはない", 801 | step, res, err, 802 | WithStatusCode(200), 803 | WithCacheControlPrivate(), 804 | WithSuccessResponse(func(r ResponseAPIGetRecentPlaylists) error { 805 | var found bool 806 | if len(r.Playlists) != 100 { 807 | return fmt.Errorf("最近作成されたプレイリストは100件あるはずですが%d件です", len(r.Playlists)) 808 | } 809 | for _, p := range r.Playlists { 810 | if p.ULID == banUserPlaylist.ULID { 811 | found = true 812 | break 813 | } 814 | } 815 | if found { 816 | return fmt.Errorf("最近作成したプレイリストにbanされたユーザーのプレイリストが含まれています") 817 | } 818 | return nil 819 | }), 820 | ) 821 | if !v.IsEmpty() { 822 | return v 823 | } 824 | } 825 | { 826 | // admin unban 827 | res, err := AdminBanAction(ctx, banUser, false, adminAg) 828 | v := ValidateResponse("adminがbanを解除", 829 | step, res, err, 830 | WithStatusCode(200), 831 | WithCacheControlPrivate(), 832 | WithSuccessResponse(func(r ResponseAdminBan) error { 833 | if r.UserAccount != banUser.Account { 834 | return fmt.Errorf("ban解除したユーザー %s がレスポンス %s と一致しません", banUser.Account, r.UserAccount) 835 | } 836 | if r.IsBan { 837 | return fmt.Errorf("ban解除したユーザー %s がまだbanされています", banUser.Account) 838 | } 839 | return nil 840 | }), 841 | ) 842 | if !v.IsEmpty() { 843 | return v 844 | } 845 | } 846 | // ban操作のあと3秒は猶予がある 847 | time.Sleep(3 * time.Second) 848 | { 849 | res, err := GetPlaylistAction(ctx, banUserPlaylist.ULID, adminAg) 850 | v := ValidateResponse("ban解除されたユーザーのプレイリスト詳細", 851 | step, res, err, 852 | WithStatusCode(200), 853 | WithCacheControlPrivate(), 854 | ) 855 | if !v.IsEmpty() { 856 | return v 857 | } 858 | } 859 | { 860 | res, err := LoginAction(ctx, banUser, banUserAg) 861 | v := ValidateResponse("ban解除されたユーザーがログイン", 862 | step, res, err, 863 | WithStatusCode(200), 864 | ) 865 | if !v.IsEmpty() { 866 | return v 867 | } 868 | } 869 | { 870 | res, err := GetPlaylistsAction(ctx, banUserAg) 871 | v := ValidateResponse("ban解除されたユーザーが自分のプレイリストを取得", 872 | step, res, err, 873 | WithStatusCode(200), 874 | WithCacheControlPrivate(), 875 | ) 876 | if !v.IsEmpty() { 877 | return v 878 | } 879 | } 880 | return nil 881 | } 882 | --------------------------------------------------------------------------------