Compare commits

...

23 Commits
4.4.1 ... 4.5.0

Author SHA1 Message Date
ErickSkrauch
37cc8cda32 Prepare 4.5.0 release 2020-05-01 21:38:18 +03:00
ErickSkrauch
620bb95c74 Fix in_memory_textures_storage_test [skip deploy] 2020-05-01 20:11:49 +03:00
ErickSkrauch
fd05220299 Ensure that queue for Mojang textures provider is initialized before any job will be scheduled 2020-05-01 17:36:37 +03:00
ErickSkrauch
dfe024756e Fix default redis pool size value 2020-05-01 03:57:22 +03:00
ErickSkrauch
66ef76ce6d Handle SIGTERM as a valid stop signal for a graceful shutdown since it's the default stop code for the Docker 2020-05-01 03:06:45 +03:00
ErickSkrauch
aabf54e318 Added new stats reporter to check suitable redis pool size 2020-05-01 02:46:12 +03:00
ErickSkrauch
5dbe6af1d0 Added --cpuprofile flag for the dev Docker images 2020-05-01 00:06:56 +03:00
ErickSkrauch
4c21fc5c90 Implemented health checker for textures provider from Mojang's API 2020-04-30 23:16:22 +03:00
ErickSkrauch
2ea094bbf6 Really fix usernames cache hit events 2020-04-30 00:44:31 +03:00
ErickSkrauch
c4566a337b Rework in_memory_textures_storage. Handle empty properties correctly 2020-04-30 00:24:41 +03:00
ErickSkrauch
05c68c6ba6 Fixes CHRLY-B. Handle the case when the textures property is not presented in Mojang's response 2020-04-29 21:54:40 +03:00
ErickSkrauch
8001eab9db Add rough sentry reporting to catch panic in the mojang textures decoder 2020-04-29 21:15:13 +03:00
ErickSkrauch
33b286cba0 Improve test case for redis.GetUuid 2020-04-28 18:13:01 +03:00
ErickSkrauch
f997fdf9b0 Resolves #26. Rework UUIDs storage interface to simplify results handling 2020-04-28 17:57:51 +03:00
ErickSkrauch
be30c23823 Merge branch '4.5.0' 2020-04-26 22:06:59 +03:00
ErickSkrauch
f43c1a9a37 Resolves #23. Allow to spoof Mojang's API addresses 2020-04-26 21:56:03 +03:00
ErickSkrauch
585318d307 Another attempt to fix FullBus test 2020-04-26 21:05:54 +03:00
ErickSkrauch
b2e501af60 Fix FullBus test 2020-04-26 20:58:46 +03:00
ErickSkrauch
d8f6786c69 Merge pull request #25 from elyby/24_batch_uuids_provider_strategies
FullBus stategy
2020-04-26 18:00:48 +03:00
ErickSkrauch
30c095525c Update README and CHANGELOG 2020-04-26 17:55:02 +03:00
ErickSkrauch
436d98e1a0 Fix stats reporting for batch UUIDs provider 2020-04-26 16:34:46 +03:00
ErickSkrauch
1b9e943c0e Fixed strategies implementations, added tests 2020-04-26 03:48:23 +03:00
ErickSkrauch
29b6bc89b3 Extracted strategy from batch uuids provider implementation.
Reimplemented Periodic strategy.
Implemented FullBus strategy (#24).
Started working on tests.
2020-04-24 19:38:37 +03:00
31 changed files with 1207 additions and 583 deletions

View File

@@ -48,9 +48,12 @@ jobs:
- docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD"
- export DOCKER_TAG="${TRAVIS_TAG:-dev}"
- export APP_VERSION="${TRAVIS_TAG:-dev-${TRAVIS_COMMIT:0:7}}"
- export BUILD_TAGS=""
- if [ "$DOCKER_TAG" == "dev" ]; then export BUILD_TAGS="$BUILD_TAGS --tags profiling"; fi
- >
env CGO_ENABLED=0 GOOS=linux GOARCH=amd64
go build
$BUILD_TAGS
-o release/chrly
-ldflags "-extldflags '-static' -X github.com/elyby/chrly/version.version=$APP_VERSION -X github.com/elyby/chrly/version.commit=$TRAVIS_COMMIT"
main.go

View File

@@ -6,6 +6,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] - xxxx-xx-xx
## [4.5.0] - 2020-05-01
### Added
- [#24](https://github.com/elyby/chrly/issues/24): Implemented a new strategy for the queue in the batch provider of
Mojang UUIDs: `full-bus`.
- New configuration param `QUEUE_STRATEGY` with the default value `periodic`.
- New configuration params: `MOJANG_API_BASE_URL` and `MOJANG_SESSION_SERVER_BASE_URL`, that allow you to spoof
Mojang API base addresses.
- New health checker, that ensures that response for textures provider from Mojang's API is valid.
- `dev` Docker images now have the `--cpuprofile` flag, which allows you to run the program with CPU profiling.
- New StatsD metrics:
- Gauges:
- `ely.skinsystem.{hostname}.app.redis.pool.available`
### Fixed
- Handle the case when there is no textures property in Mojang's response.
- Handle `SIGTERM` as a valid stop signal for a graceful shutdown since it's the default stop code for the Docker.
- Default connections pool size for Redis.
### Changed
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time` timer will not be recorded if the iteration was
empty.
## [4.4.1] - 2020-04-24
### Added
- [#20](https://github.com/elyby/chrly/issues/20): Print hostname in the `version` command output.
@@ -124,7 +146,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
from the textures link instead.
- `hash` field from `POST /api/skins` endpoint.
[Unreleased]: https://github.com/elyby/chrly/compare/4.4.1...HEAD
[Unreleased]: https://github.com/elyby/chrly/compare/4.5.0...HEAD
[4.5.0]: https://github.com/elyby/chrly/compare/4.4.1...4.5.0
[4.4.1]: https://github.com/elyby/chrly/compare/4.4.0...4.4.1
[4.4.0]: https://github.com/elyby/chrly/compare/4.3.0...4.4.0
[4.3.0]: https://github.com/elyby/chrly/compare/4.2.3...4.3.0

15
Gopkg.lock generated
View File

@@ -265,7 +265,7 @@
version = "v0.1.1"
[[projects]]
digest = "1:381bcbeb112a51493d9d998bbba207a529c73dbb49b3fd789e48c63fac1f192c"
digest = "1:cc4eb6813da8d08694e557fcafae8fcc24f47f61a0717f952da130ca9a486dfc"
name = "github.com/stretchr/testify"
packages = [
"assert",
@@ -274,16 +274,8 @@
"suite",
]
pruneopts = ""
revision = "ffdc059bfe9ce6a4e144ba849dbedead332c6053"
version = "v1.3.0"
[[projects]]
branch = "master"
digest = "1:86e6712cfd4070a2120c03fcec41cfcbbc51813504a74e28d74479edfaf669ee"
name = "github.com/tevino/abool"
packages = ["."]
pruneopts = ""
revision = "9b9efcf221b50905aab9bbabd3daed56dc10f339"
revision = "3ebf1ddaeb260c4b1ae502a01c7844fa8c1fa0e9"
version = "v1.5.1"
[[projects]]
digest = "1:061754b9de261d8e1cf804970dff7b3e105d1cb4883ef446dbe911489ba8e9eb"
@@ -352,7 +344,6 @@
"github.com/stretchr/testify/mock",
"github.com/stretchr/testify/require",
"github.com/stretchr/testify/suite",
"github.com/tevino/abool",
"github.com/thedevsaddam/govalidator",
]
solver-name = "gps-cdcl"

View File

@@ -32,10 +32,6 @@ ignored = ["github.com/elyby/chrly"]
name = "github.com/thedevsaddam/govalidator"
version = "^1.9.6"
[[constraint]]
name = "github.com/tevino/abool"
branch = "master"
[[constraint]]
name = "github.com/asaskevich/EventBus"
source = "https://github.com/erickskrauch/EventBus.git"

View File

@@ -97,6 +97,14 @@ docker-compose up -d app
<td>Sentry can be used to collect app errors</td>
<td><code>https://public:private@your.sentry.io/1</code></td>
</tr>
<tr>
<td>QUEUE_STRATEGY</td>
<td>
Sets the strategy for the queue in the batch provider of Mojang UUIDs. Allowed values are <code>periodic</code>
and <code>full-bus</code> (see <a href="https://github.com/elyby/chrly/issues/24">#24</a>).
</td>
<td><code>periodic</code></td>
</tr>
<tr>
<td>QUEUE_LOOP_DELAY</td>
<td>
@@ -137,6 +145,20 @@ docker-compose up -d app
</td>
<td><code>http://remote-provider.com/api/worker/mojang-uuid</code></td>
</tr>
<tr>
<td>MOJANG_API_BASE_URL</td>
<td>
Allows you to spoof the Mojang's API server address.
</td>
<td><code>https://api.mojang.com</code></td>
</tr>
<tr>
<td>MOJANG_SESSION_SERVER_BASE_URL</td>
<td>
Allows you to spoof the Mojang's Session server address.
</td>
<td><code>https://sessionserver.mojang.com</code></td>
</tr>
<tr>
<td>TEXTURES_EXTRA_PARAM_NAME</td>
<td>

View File

@@ -7,6 +7,7 @@ import (
"io/ioutil"
"net/http"
"strings"
"sync"
"time"
)
@@ -18,14 +19,17 @@ var HttpClient = &http.Client{
}
type SignedTexturesResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Props []*Property `json:"properties"`
Id string `json:"id"`
Name string `json:"name"`
Props []*Property `json:"properties"`
once sync.Once
decodedTextures *TexturesProp
decodedErr error
}
func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp {
if t.decodedTextures == nil {
func (t *SignedTexturesResponse) DecodeTextures() (*TexturesProp, error) {
t.once.Do(func() {
var texturesProp string
for _, prop := range t.Props {
if prop.Name == "textures" {
@@ -35,14 +39,18 @@ func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp {
}
if texturesProp == "" {
return nil
return
}
decodedTextures, _ := DecodeTextures(texturesProp)
t.decodedTextures = decodedTextures
}
decodedTextures, err := DecodeTextures(texturesProp)
if err != nil {
t.decodedErr = err
} else {
t.decodedTextures = decodedTextures
}
})
return t.decodedTextures
return t.decodedTextures, t.decodedErr
}
type Property struct {
@@ -58,11 +66,17 @@ type ProfileInfo struct {
IsDemo bool `json:"demo,omitempty"`
}
var ApiMojangDotComAddr = "https://api.mojang.com"
var SessionServerMojangComAddr = "https://sessionserver.mojang.com"
// Exchanges usernames array to array of uuids
// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs
func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
requestBody, _ := json.Marshal(usernames)
request, _ := http.NewRequest("POST", "https://api.mojang.com/profiles/minecraft", bytes.NewBuffer(requestBody))
request, err := http.NewRequest("POST", ApiMojangDotComAddr+"/profiles/minecraft", bytes.NewBuffer(requestBody))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
@@ -88,12 +102,15 @@ func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
func UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
url := "https://sessionserver.mojang.com/session/minecraft/profile/" + normalizedUuid
url := SessionServerMojangComAddr + "/session/minecraft/profile/" + normalizedUuid
if signed {
url += "?unsigned=false"
}
request, _ := http.NewRequest("GET", url, nil)
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
response, err := HttpClient.Do(request)
if err != nil {

View File

@@ -20,7 +20,8 @@ func TestSignedTexturesResponse(t *testing.T) {
},
},
}
textures := obj.DecodeTextures()
textures, err := obj.DecodeTextures()
testify.Nil(t, err)
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
})
@@ -30,7 +31,8 @@ func TestSignedTexturesResponse(t *testing.T) {
Name: "mock",
Props: []*Property{},
}
textures := obj.DecodeTextures()
textures, err := obj.DecodeTextures()
testify.Nil(t, err)
testify.Nil(t, textures)
})
}

55
cmd/root_profiling.go Normal file
View File

@@ -0,0 +1,55 @@
// +build profiling
package cmd
import (
"log"
"os"
"runtime/pprof"
"github.com/spf13/cobra"
)
func init() {
var profilePath string
RootCmd.PersistentFlags().StringVar(&profilePath, "cpuprofile", "", "enables pprof profiling and sets its output path")
pprofEnabled := false
originalPersistentPreRunE := RootCmd.PersistentPreRunE
RootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
if profilePath == "" {
return nil
}
f, err := os.Create(profilePath)
if err != nil {
return err
}
log.Println("enabling profiling")
err = pprof.StartCPUProfile(f)
if err != nil {
return err
}
pprofEnabled = true
if originalPersistentPreRunE != nil {
return originalPersistentPreRunE(cmd, args)
}
return nil
}
originalPersistentPostRun := RootCmd.PersistentPreRun
RootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) {
if pprofEnabled {
log.Println("shutting down profiling")
pprof.StopCPUProfile()
}
if originalPersistentPostRun != nil {
originalPersistentPostRun(cmd, args)
}
}
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/mediocregopher/radix.v2/util"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/mojangtextures"
)
var now = time.Now
@@ -186,20 +185,21 @@ func removeByUsername(username string, conn util.Cmder) error {
return nil
}
func (db *Redis) GetUuid(username string) (string, error) {
func (db *Redis) GetUuid(username string) (string, bool, error) {
conn, err := db.pool.Get()
if err != nil {
return "", err
return "", false, err
}
defer db.pool.Put(conn)
return findMojangUuidByUsername(username, conn)
}
func findMojangUuidByUsername(username string, conn util.Cmder) (string, error) {
response := conn.Cmd("HGET", mojangUsernameToUuidKey, strings.ToLower(username))
func findMojangUuidByUsername(username string, conn util.Cmder) (string, bool, error) {
key := strings.ToLower(username)
response := conn.Cmd("HGET", mojangUsernameToUuidKey, key)
if response.IsType(redis.Nil) {
return "", &mojangtextures.ValueNotFound{}
return "", false, nil
}
data, _ := response.Str()
@@ -207,10 +207,11 @@ func findMojangUuidByUsername(username string, conn util.Cmder) (string, error)
timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
storedAt := time.Unix(timestamp, 0)
if storedAt.Add(time.Hour * 24 * 30).Before(now()) {
return "", &mojangtextures.ValueNotFound{}
conn.Cmd("HDEL", mojangUsernameToUuidKey, key)
return "", false, nil
}
return parts[0], nil
return parts[0], true, nil
}
func (db *Redis) StoreUuid(username string, uuid string) error {
@@ -242,6 +243,10 @@ func (db *Redis) Ping() error {
return nil
}
func (db *Redis) Avail() int {
return db.pool.Avail()
}
func buildUsernameKey(username string) string {
return "username:" + strings.ToLower(username)
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/mojangtextures"
)
const redisAddr = "localhost:6379"
@@ -317,15 +316,30 @@ func (suite *redisTestSuite) TestGetUuid() {
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
)
uuid, err := suite.Redis.GetUuid("Mock")
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().True(found)
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
})
suite.RunSubTest("exists record with empty uuid value", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf(":%d", time.Now().Unix()),
)
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().True(found)
suite.Require().Empty("", uuid)
})
suite.RunSubTest("not exists record", func() {
uuid, err := suite.Redis.GetUuid("Mock")
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().False(found)
suite.Require().Empty(uuid)
suite.Require().IsType(new(mojangtextures.ValueNotFound), err)
})
suite.RunSubTest("exists, but expired record", func() {
@@ -335,9 +349,13 @@ func (suite *redisTestSuite) TestGetUuid() {
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Add(-1*time.Hour*24*31).Unix()),
)
uuid, err := suite.Redis.GetUuid("Mock")
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().IsType(new(mojangtextures.ValueNotFound), err)
suite.Require().False(found)
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().True(resp.IsType(redis.Nil), "should cleanup expired records")
})
}
@@ -359,3 +377,8 @@ func (suite *redisTestSuite) TestPing() {
err := suite.Redis.Ping()
suite.Require().Nil(err)
}
func (suite *redisTestSuite) TestAvail() {
avail := suite.Redis.Avail()
suite.Require().True(avail > 0)
}

View File

@@ -1,8 +1,10 @@
package di
import (
"context"
"fmt"
"path"
"time"
"github.com/goava/di"
"github.com/spf13/viper"
@@ -22,7 +24,7 @@ import (
var db = di.Options(
di.Provide(newRedis,
di.As(new(http.SkinsRepository)),
di.As(new(mojangtextures.UuidsStorage)),
di.As(new(mojangtextures.UUIDsStorage)),
),
di.Provide(newFSFactory,
di.As(new(http.CapesRepository)),
@@ -33,7 +35,7 @@ var db = di.Options(
func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error) {
config.SetDefault("storage.redis.host", "localhost")
config.SetDefault("storage.redis.port", 6379)
config.SetDefault("storage.redis.poll", 10)
config.SetDefault("storage.redis.poolSize", 10)
conn, err := redis.New(
fmt.Sprintf("%s:%d", config.GetString("storage.redis.host"), config.GetInt("storage.redis.port")),
@@ -43,6 +45,12 @@ func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error
return nil, err
}
if err := container.Provide(func() es.ReporterFunc {
return es.AvailableRedisPoolSizeReporter(conn, time.Second, context.Background())
}, di.As(new(es.Reporter))); err != nil {
return nil, err
}
if err := container.Provide(func() *namedHealthChecker {
return &namedHealthChecker{
Name: "redis",
@@ -66,8 +74,5 @@ func newFSFactory(config *viper.Viper) (*fs.Filesystem, error) {
}
func newMojangSignedTexturesStorage() mojangtextures.TexturesStorage {
texturesStorage := mojangtextures.NewInMemoryTexturesStorage()
texturesStorage.Start()
return texturesStorage
return mojangtextures.NewInMemoryTexturesStorage()
}

View File

@@ -74,6 +74,11 @@ func newHandlerFactory(
mount(router, "/api", apiRouter)
}
err := container.Invoke(enableReporters)
if err != nil {
return nil, err
}
// Resolve health checkers last, because all the services required by the application
// must first be initialized and each of them can publish its own checkers
var healthCheckers []*namedHealthChecker

View File

@@ -13,6 +13,7 @@ import (
"github.com/mono83/slf/wd"
"github.com/spf13/viper"
"github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/version"
)
@@ -95,3 +96,9 @@ func newStatsReporter(config *viper.Viper) (slf.StatsReporter, error) {
return wd.Custom("", "", dispatcher), nil
}
func enableReporters(reporter slf.StatsReporter, factories []eventsubscribers.Reporter) {
for _, factory := range factories {
factory.Enable(reporter)
}
}

View File

@@ -1,6 +1,7 @@
package di
import (
"context"
"fmt"
"net/url"
"time"
@@ -8,21 +9,50 @@ import (
"github.com/goava/di"
"github.com/spf13/viper"
"github.com/elyby/chrly/api/mojang"
es "github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var mojangTextures = di.Options(
di.Invoke(interceptMojangApiUrls),
di.Provide(newMojangTexturesProviderFactory),
di.Provide(newMojangTexturesProvider),
di.Provide(newMojangTexturesUuidsProviderFactory),
di.Provide(newMojangTexturesBatchUUIDsProvider),
di.Provide(newMojangTexturesBatchUUIDsProviderStrategyFactory),
di.Provide(newMojangTexturesBatchUUIDsProviderDelayedStrategy),
di.Provide(newMojangTexturesBatchUUIDsProviderFullBusStrategy),
di.Provide(newMojangTexturesRemoteUUIDsProvider),
di.Provide(newMojangSignedTexturesProvider),
di.Provide(newMojangTexturesStorageFactory),
)
func interceptMojangApiUrls(config *viper.Viper) error {
apiUrl := config.GetString("mojang.api_base_url")
if apiUrl != "" {
u, err := url.ParseRequestURI(apiUrl)
if err != nil {
return err
}
mojang.ApiMojangDotComAddr = u.String()
}
sessionServerUrl := config.GetString("mojang.session_server_base_url")
if sessionServerUrl != "" {
u, err := url.ParseRequestURI(apiUrl)
if err != nil {
return err
}
mojang.SessionServerMojangComAddr = u.String()
}
return nil
}
func newMojangTexturesProviderFactory(
container *di.Container,
config *viper.Viper,
@@ -75,7 +105,7 @@ func newMojangTexturesUuidsProviderFactory(
func newMojangTexturesBatchUUIDsProvider(
container *di.Container,
config *viper.Viper,
strategy mojangtextures.BatchUuidsProviderStrategy,
emitter mojangtextures.Emitter,
) (*mojangtextures.BatchUuidsProvider, error) {
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
@@ -106,17 +136,60 @@ func newMojangTexturesBatchUUIDsProvider(
return nil, err
}
return mojangtextures.NewBatchUuidsProvider(context.Background(), strategy, emitter), nil
}
func newMojangTexturesBatchUUIDsProviderStrategyFactory(
container *di.Container,
config *viper.Viper,
) (mojangtextures.BatchUuidsProviderStrategy, error) {
config.SetDefault("queue.strategy", "periodic")
strategyName := config.GetString("queue.strategy")
switch strategyName {
case "periodic":
var strategy *mojangtextures.PeriodicStrategy
err := container.Resolve(&strategy)
if err != nil {
return nil, err
}
return strategy, nil
case "full-bus":
var strategy *mojangtextures.FullBusStrategy
err := container.Resolve(&strategy)
if err != nil {
return nil, err
}
return strategy, nil
default:
return nil, fmt.Errorf("unknown queue strategy \"%s\"", strategyName)
}
}
func newMojangTexturesBatchUUIDsProviderDelayedStrategy(config *viper.Viper) *mojangtextures.PeriodicStrategy {
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
config.SetDefault("queue.batch_size", 10)
return &mojangtextures.BatchUuidsProvider{
Emitter: emitter,
IterationDelay: config.GetDuration("queue.loop_delay"),
IterationSize: config.GetInt("queue.batch_size"),
}, nil
return mojangtextures.NewPeriodicStrategy(
config.GetDuration("queue.loop_delay"),
config.GetInt("queue.batch_size"),
)
}
func newMojangTexturesBatchUUIDsProviderFullBusStrategy(config *viper.Viper) *mojangtextures.FullBusStrategy {
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
config.SetDefault("queue.batch_size", 10)
return mojangtextures.NewFullBusStrategy(
config.GetDuration("queue.loop_delay"),
config.GetInt("queue.batch_size"),
)
}
func newMojangTexturesRemoteUUIDsProvider(
container *di.Container,
config *viper.Viper,
emitter mojangtextures.Emitter,
) (*mojangtextures.RemoteApiUuidsProvider, error) {
@@ -125,6 +198,20 @@ func newMojangTexturesRemoteUUIDsProvider(
return nil, fmt.Errorf("unable to parse remote url: %w", err)
}
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
config.SetDefault("healthcheck.mojang_api_textures_provider_cool_down_duration", time.Minute+10*time.Second)
return &namedHealthChecker{
Name: "mojang-api-textures-provider-response-checker",
Checker: es.MojangApiTexturesProviderResponseChecker(
emitter,
config.GetDuration("healthcheck.mojang_api_textures_provider_cool_down_duration"),
),
}
}); err != nil {
return nil, err
}
return &mojangtextures.RemoteApiUuidsProvider{
Emitter: emitter,
Url: *remoteUrl,
@@ -138,11 +225,11 @@ func newMojangSignedTexturesProvider(emitter mojangtextures.Emitter) mojangtextu
}
func newMojangTexturesStorageFactory(
uuidsStorage mojangtextures.UuidsStorage,
uuidsStorage mojangtextures.UUIDsStorage,
texturesStorage mojangtextures.TexturesStorage,
) mojangtextures.Storage {
return &mojangtextures.SeparatedStorage{
UuidsStorage: uuidsStorage,
UUIDsStorage: uuidsStorage,
TexturesStorage: texturesStorage,
}
}

View File

@@ -32,33 +32,16 @@ func DatabaseChecker(connection Pingable) healthcheck.CheckerFunc {
}
func MojangBatchUuidsProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc {
var mutex sync.Mutex
var lastCallErr error
var expireTimer *time.Timer
errHolder := &expiringErrHolder{D: resetDuration}
dispatcher.Subscribe(
"mojang_textures:batch_uuids_provider:result",
func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
mutex.Lock()
defer mutex.Unlock()
lastCallErr = err
if expireTimer != nil {
expireTimer.Stop()
}
expireTimer = time.AfterFunc(resetDuration, func() {
mutex.Lock()
lastCallErr = nil
mutex.Unlock()
})
errHolder.Set(err)
},
)
return func(ctx context.Context) error {
mutex.Lock()
defer mutex.Unlock()
return lastCallErr
return errHolder.Get()
}
}
@@ -82,3 +65,47 @@ func MojangBatchUuidsProviderQueueLengthChecker(dispatcher Subscriber, maxLength
return errors.New("the maximum number of tasks in the queue has been exceeded")
}
}
func MojangApiTexturesProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc {
errHolder := &expiringErrHolder{D: resetDuration}
dispatcher.Subscribe(
"mojang_textures:mojang_api_textures_provider:after_request",
func(uuid string, profile *mojang.SignedTexturesResponse, err error) {
errHolder.Set(err)
},
)
return func(ctx context.Context) error {
return errHolder.Get()
}
}
type expiringErrHolder struct {
D time.Duration
err error
l sync.Mutex
t *time.Timer
}
func (h *expiringErrHolder) Get() error {
h.l.Lock()
defer h.l.Unlock()
return h.err
}
func (h *expiringErrHolder) Set(err error) {
h.l.Lock()
defer h.l.Unlock()
if h.t != nil {
h.t.Stop()
h.t = nil
}
h.err = err
if err != nil {
h.t = time.AfterFunc(h.D, func() {
h.Set(nil)
})
}
}

View File

@@ -56,7 +56,7 @@ func TestMojangBatchUuidsProviderChecker(t *testing.T) {
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
//
t.Run("when no error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
@@ -107,3 +107,40 @@ func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) {
}
})
}
func TestMojangApiTexturesProviderResponseChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
t.Run("when no error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
d.Emit("mojang_textures:mojang_api_textures_provider:after_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
&mojang.SignedTexturesResponse{},
nil,
)
assert.Nil(t, checker(context.Background()))
})
t.Run("when error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, err)
assert.Equal(t, err, checker(context.Background()))
})
t.Run("should reset value after passed duration", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, 20*time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, err)
assert.Equal(t, err, checker(context.Background()))
time.Sleep(40 * time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
}

View File

@@ -1,6 +1,7 @@
package eventsubscribers
import (
"context"
"net/http"
"strings"
"sync"
@@ -19,6 +20,17 @@ type StatsReporter struct {
timersMutex sync.Mutex
}
type Reporter interface {
Enable(reporter slf.StatsReporter)
}
type ReporterFunc func(reporter slf.StatsReporter)
func (f ReporterFunc) Enable(reporter slf.StatsReporter) {
f(reporter)
}
// TODO: rework all reporters in the same style as AvailableRedisPoolSizeReporter
func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
s.timersMap = make(map[string]time.Time)
@@ -34,15 +46,15 @@ func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
// Mojang signed textures source events
d.Subscribe("mojang_textures:call", s.incCounterHandler("mojang_textures.request"))
d.Subscribe("mojang_textures:usernames:after_cache", func(username string, uuid string, err error) {
if err != nil {
d.Subscribe("mojang_textures:usernames:after_cache", func(username string, uuid string, found bool, err error) {
if err != nil || !found {
return
}
if uuid == "" {
s.IncCounter("mojang_textures:usernames:cache_hit_nil", 1)
s.IncCounter("mojang_textures.usernames.cache_hit_nil", 1)
} else {
s.IncCounter("mojang_textures:usernames:cache_hit", 1)
s.IncCounter("mojang_textures.usernames.cache_hit", 1)
}
})
d.Subscribe("mojang_textures:textures:after_cache", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
@@ -96,12 +108,12 @@ func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
d.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, queueSize int) {
s.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(usernames)))
s.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize))
if len(usernames) != 0 {
s.startTimeRecording("batch_uuids_provider_round_time_" + strings.Join(usernames, "|"))
}
})
d.Subscribe("mojang_textures:batch_uuids_provider:before_round", func() {
s.startTimeRecording("batch_uuids_provider_round_time")
})
d.Subscribe("mojang_textures:batch_uuids_provider:after_round", func() {
s.finalizeTimeRecording("batch_uuids_provider_round_time", "mojang_textures.usernames.round_time")
d.Subscribe("mojang_textures:batch_uuids_provider:result", func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
s.finalizeTimeRecording("batch_uuids_provider_round_time_"+strings.Join(usernames, "|"), "mojang_textures.usernames.round_time")
})
}
@@ -175,3 +187,24 @@ func (s *StatsReporter) finalizeTimeRecording(timeKey string, statName string) {
s.RecordTimer(statName, time.Since(startedAt))
}
type RedisPoolCheckable interface {
Avail() int
}
func AvailableRedisPoolSizeReporter(pool RedisPoolCheckable, d time.Duration, stop context.Context) ReporterFunc {
return func(reporter slf.StatsReporter) {
go func() {
ticker := time.NewTicker(d)
for {
select {
case <-stop.Done():
ticker.Stop()
return
case <-ticker.C:
reporter.UpdateGauge("redis.pool.available", int64(pool.Avail()))
}
}
}()
}
}

View File

@@ -1,6 +1,7 @@
package eventsubscribers
import (
"context"
"errors"
"net/http/httptest"
"testing"
@@ -213,24 +214,30 @@ var statsReporterTestCases = []*StatsReporterTestCase{
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", errors.New("error")},
{"mojang_textures:usernames:after_cache", "username", "", false, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", nil},
{"mojang_textures:usernames:after_cache", "username", "", false, nil},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", true, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit_nil", int64(1)},
{"IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil},
{"mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit", int64(1)},
{"IncCounter", "mojang_textures.usernames.cache_hit", int64(1)},
},
},
{
@@ -337,19 +344,24 @@ var statsReporterTestCases = []*StatsReporterTestCase{
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:round", []string{"username1", "username2"}, 5},
{"mojang_textures:batch_uuids_provider:result", []string{"username1", "username2"}, []*mojang.ProfileInfo{}, nil},
},
ExpectedCalls: [][]interface{}{
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)},
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(5)},
{"RecordTimer", "mojang_textures.usernames.round_time", mock.AnythingOfType("time.Duration")},
},
},
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:before_round"},
{"mojang_textures:batch_uuids_provider:after_round"},
{"mojang_textures:batch_uuids_provider:round", []string{}, 0},
// This event will be not emitted, but we emit it to ensure, that RecordTimer will not be called
{"mojang_textures:batch_uuids_provider:result", []string{}, []*mojang.ProfileInfo{}, nil},
},
ExpectedCalls: [][]interface{}{
{"RecordTimer", "mojang_textures.usernames.round_time", mock.AnythingOfType("time.Duration")},
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(0)},
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)},
// Should not call RecordTimer
},
},
}
@@ -381,3 +393,30 @@ func TestStatsReporter(t *testing.T) {
})
}
}
type redisPoolCheckableMock struct {
mock.Mock
}
func (r *redisPoolCheckableMock) Avail() int {
return r.Called().Int(0)
}
func TestAvailableRedisPoolSizeReporter(t *testing.T) {
poolMock := &redisPoolCheckableMock{}
poolMock.On("Avail").Return(5).Times(3)
reporterMock := &StatsReporterMock{}
reporterMock.On("UpdateGauge", "redis.pool.available", int64(5)).Times(3)
ctx, cancel := context.WithCancel(context.Background())
creator := AvailableRedisPoolSizeReporter(poolMock, 10*time.Millisecond, ctx)
creator(reporterMock)
time.Sleep(35 * time.Millisecond)
cancel()
poolMock.AssertExpectations(t)
reporterMock.AssertExpectations(t)
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"os/signal"
"strings"
"syscall"
"github.com/gorilla/mux"
"github.com/mono83/slf"
@@ -28,9 +29,8 @@ func StartServer(server *http.Server, logger slf.Logger) {
logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Emergency("Error in main(): :err", wd.ErrParam(err))
close(done)
}
close(done)
}()
go func() {
@@ -46,7 +46,7 @@ func StartServer(server *http.Server, logger slf.Logger) {
func waitForExitSignal() os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, os.Kill)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM, os.Kill)
return <-ch
}

View File

@@ -2,7 +2,6 @@ package http
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
@@ -66,7 +65,12 @@ func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.R
return
}
texturesProp := mojangTextures.DecodeTextures()
texturesProp, _ := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusNotFound)
return
}
skin := texturesProp.Textures.Skin
if skin == nil {
response.WriteHeader(http.StatusNotFound)
@@ -104,7 +108,12 @@ func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.R
return
}
texturesProp := mojangTextures.DecodeTextures()
texturesProp, _ := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusNotFound)
return
}
cape := texturesProp.Textures.Cape
if cape == nil {
response.WriteHeader(http.StatusNotFound)
@@ -162,10 +171,9 @@ func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *ht
return
}
texturesProp := mojangTextures.DecodeTextures()
texturesProp, _ := mojangTextures.DecodeTextures()
if texturesProp == nil {
ctx.Emit("skinsystem:error", errors.New("unable to find textures property"))
apiServerError(response)
response.WriteHeader(http.StatusNoContent)
return
}

View File

@@ -166,7 +166,7 @@ var skinsTestsCases = []*skinsystemTestCase{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
@@ -174,10 +174,20 @@ var skinsTestsCases = []*skinsystemTestCase{
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no skin texture",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createEmptyMojangResponse(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
@@ -270,7 +280,7 @@ var capesTestsCases = []*skinsystemTestCase{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, true), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
@@ -278,10 +288,20 @@ var capesTestsCases = []*skinsystemTestCase{
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no cape texture",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createEmptyMojangResponse(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
@@ -439,7 +459,7 @@ var texturesTestsCases = []*skinsystemTestCase{
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(true, true), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
@@ -456,11 +476,22 @@ var texturesTestsCases = []*skinsystemTestCase{
},
},
{
Name: "Username not exists, but Mojang profile available, but there is no textures",
Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(false, false), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
},
},
{
Name: "Username not exists, but Mojang profile available, but there is an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
@@ -567,7 +598,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
AllowProxy: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
@@ -666,7 +697,15 @@ func createCapeModel() *model.Cape {
return &model.Cape{File: bytes.NewReader(createCape())}
}
func createMojangResponse(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse {
func createEmptyMojangResponse() *mojang.SignedTexturesResponse {
return &mojang.SignedTexturesResponse{
Id: "00000000000000000000000000000000",
Name: "mock_username",
Props: []*mojang.Property{},
}
}
func createMojangResponseWithTextures(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse {
timeZone, _ := time.LoadLocation("Europe/Minsk")
textures := &mojang.TexturesProp{
Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).Unix(),
@@ -687,16 +726,11 @@ func createMojangResponse(includeSkin bool, includeCape bool) *mojang.SignedText
}
}
response := &mojang.SignedTexturesResponse{
Id: "00000000000000000000000000000000",
Name: "mock_username",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(textures),
},
},
}
response := createEmptyMojangResponse()
response.Props = append(response.Props, &mojang.Property{
Name: "textures",
Value: mojang.EncodeTextures(textures),
})
return response
}

View File

@@ -1,6 +1,7 @@
package mojangtextures
import (
"context"
"strings"
"sync"
"time"
@@ -9,131 +10,240 @@ import (
)
type jobResult struct {
profile *mojang.ProfileInfo
error error
Profile *mojang.ProfileInfo
Error error
}
type jobItem struct {
username string
respondChan chan *jobResult
type job struct {
Username string
RespondChan chan *jobResult
}
type jobsQueue struct {
lock sync.Mutex
items []*jobItem
items []*job
}
func (s *jobsQueue) New() *jobsQueue {
s.items = []*jobItem{}
return s
}
func (s *jobsQueue) Enqueue(t *jobItem) {
s.lock.Lock()
defer s.lock.Unlock()
s.items = append(s.items, t)
}
func (s *jobsQueue) Dequeue(n int) []*jobItem {
s.lock.Lock()
defer s.lock.Unlock()
if n > s.size() {
n = s.size()
func newJobsQueue() *jobsQueue {
return &jobsQueue{
items: []*job{},
}
items := s.items[0:n]
s.items = s.items[n:len(s.items)]
return items
}
func (s *jobsQueue) Size() int {
func (s *jobsQueue) Enqueue(job *job) int {
s.lock.Lock()
defer s.lock.Unlock()
return s.size()
}
s.items = append(s.items, job)
func (s *jobsQueue) size() int {
return len(s.items)
}
func (s *jobsQueue) Dequeue(n int) ([]*job, int) {
s.lock.Lock()
defer s.lock.Unlock()
l := len(s.items)
if n > l {
n = l
}
items := s.items[0:n]
s.items = s.items[n:l]
return items, l - n
}
var usernamesToUuids = mojang.UsernamesToUuids
var forever = func() bool {
return true
type JobsIteration struct {
Jobs []*job
Queue int
c chan struct{}
}
func (j *JobsIteration) Done() {
if j.c != nil {
close(j.c)
}
}
type BatchUuidsProviderStrategy interface {
Queue(job *job)
GetJobs(abort context.Context) <-chan *JobsIteration
}
type PeriodicStrategy struct {
Delay time.Duration
Batch int
queue *jobsQueue
done chan struct{}
}
func NewPeriodicStrategy(delay time.Duration, batch int) *PeriodicStrategy {
return &PeriodicStrategy{
Delay: delay,
Batch: batch,
queue: newJobsQueue(),
}
}
func (ctx *PeriodicStrategy) Queue(job *job) {
ctx.queue.Enqueue(job)
}
func (ctx *PeriodicStrategy) GetJobs(abort context.Context) <-chan *JobsIteration {
ch := make(chan *JobsIteration)
go func() {
for {
select {
case <-abort.Done():
close(ch)
return
case <-time.After(ctx.Delay):
jobs, queueLen := ctx.queue.Dequeue(ctx.Batch)
jobDoneChan := make(chan struct{})
ch <- &JobsIteration{jobs, queueLen, jobDoneChan}
<-jobDoneChan
}
}
}()
return ch
}
type FullBusStrategy struct {
Delay time.Duration
Batch int
queue *jobsQueue
busIsFull chan bool
}
func NewFullBusStrategy(delay time.Duration, batch int) *FullBusStrategy {
return &FullBusStrategy{
Delay: delay,
Batch: batch,
queue: newJobsQueue(),
busIsFull: make(chan bool),
}
}
func (ctx *FullBusStrategy) Queue(job *job) {
n := ctx.queue.Enqueue(job)
if n%ctx.Batch == 0 {
ctx.busIsFull <- true
}
}
// Формально, это описание логики водителя маршрутки xD
func (ctx *FullBusStrategy) GetJobs(abort context.Context) <-chan *JobsIteration {
ch := make(chan *JobsIteration)
go func() {
for {
t := time.NewTimer(ctx.Delay)
select {
case <-abort.Done():
close(ch)
return
case <-t.C:
ctx.sendJobs(ch)
case <-ctx.busIsFull:
t.Stop()
ctx.sendJobs(ch)
}
}
}()
return ch
}
func (ctx *FullBusStrategy) sendJobs(ch chan *JobsIteration) {
jobs, queueLen := ctx.queue.Dequeue(ctx.Batch)
ch <- &JobsIteration{jobs, queueLen, nil}
}
type BatchUuidsProvider struct {
Emitter
IterationDelay time.Duration
IterationSize int
context context.Context
emitter Emitter
strategy BatchUuidsProviderStrategy
onFirstCall sync.Once
queue jobsQueue
}
func NewBatchUuidsProvider(
context context.Context,
strategy BatchUuidsProviderStrategy,
emitter Emitter,
) *BatchUuidsProvider {
return &BatchUuidsProvider{
context: context,
emitter: emitter,
strategy: strategy,
}
}
func (ctx *BatchUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
ctx.onFirstCall.Do(func() {
ctx.queue.New()
ctx.startQueue()
})
ctx.onFirstCall.Do(ctx.startQueue)
resultChan := make(chan *jobResult)
ctx.queue.Enqueue(&jobItem{username, resultChan})
ctx.Emit("mojang_textures:batch_uuids_provider:queued", username)
ctx.strategy.Queue(&job{username, resultChan})
ctx.emitter.Emit("mojang_textures:batch_uuids_provider:queued", username)
result := <-resultChan
return result.profile, result.error
return result.Profile, result.Error
}
func (ctx *BatchUuidsProvider) startQueue() {
// This synchronization chan is used to ensure that strategy's jobs provider
// will be initialized before any job will be scheduled
d := make(chan struct{})
go func() {
time.Sleep(ctx.IterationDelay)
for forever() {
ctx.Emit("mojang_textures:batch_uuids_provider:before_round")
ctx.queueRound()
ctx.Emit("mojang_textures:batch_uuids_provider:after_round")
time.Sleep(ctx.IterationDelay)
jobsChan := ctx.strategy.GetJobs(ctx.context)
close(d)
for {
select {
case <-ctx.context.Done():
return
case iteration := <-jobsChan:
go func() {
ctx.performRequest(iteration)
iteration.Done()
}()
}
}
}()
<-d
}
func (ctx *BatchUuidsProvider) queueRound() {
queueSize := ctx.queue.Size()
jobs := ctx.queue.Dequeue(ctx.IterationSize)
var usernames []string
for _, job := range jobs {
usernames = append(usernames, job.username)
func (ctx *BatchUuidsProvider) performRequest(iteration *JobsIteration) {
usernames := make([]string, len(iteration.Jobs))
for i, job := range iteration.Jobs {
usernames[i] = job.Username
}
ctx.Emit("mojang_textures:batch_uuids_provider:round", usernames, queueSize-len(jobs))
ctx.emitter.Emit("mojang_textures:batch_uuids_provider:round", usernames, iteration.Queue)
if len(usernames) == 0 {
return
}
profiles, err := usernamesToUuids(usernames)
ctx.Emit("mojang_textures:batch_uuids_provider:result", usernames, profiles, err)
for _, job := range jobs {
go func(job *jobItem) {
response := &jobResult{}
if err != nil {
response.error = err
} else {
// The profiles in the response aren't ordered, so we must search each username over full array
for _, profile := range profiles {
if strings.EqualFold(job.username, profile.Name) {
response.profile = profile
break
}
ctx.emitter.Emit("mojang_textures:batch_uuids_provider:result", usernames, profiles, err)
for _, job := range iteration.Jobs {
response := &jobResult{}
if err == nil {
// The profiles in the response aren't ordered, so we must search each username over full array
for _, profile := range profiles {
if strings.EqualFold(job.Username, profile.Name) {
response.Profile = profile
break
}
}
} else {
response.Error = err
}
job.respondChan <- response
}(job)
job.RespondChan <- response
close(job.RespondChan)
}
}

View File

@@ -1,64 +1,51 @@
package mojangtextures
import (
"crypto/rand"
"encoding/base64"
"strings"
"context"
"fmt"
"strconv"
"sync"
"testing"
"time"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
func TestJobsQueue(t *testing.T) {
createQueue := func() *jobsQueue {
queue := &jobsQueue{}
queue.New()
return queue
}
t.Run("Enqueue", func(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
assert.Equal(3, s.Size())
s := newJobsQueue()
require.Equal(t, 1, s.Enqueue(&job{Username: "username1"}))
require.Equal(t, 2, s.Enqueue(&job{Username: "username2"}))
require.Equal(t, 3, s.Enqueue(&job{Username: "username3"}))
})
t.Run("Dequeue", func(t *testing.T) {
assert := testify.New(t)
s := newJobsQueue()
s.Enqueue(&job{Username: "username1"})
s.Enqueue(&job{Username: "username2"})
s.Enqueue(&job{Username: "username3"})
s.Enqueue(&job{Username: "username4"})
s.Enqueue(&job{Username: "username5"})
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
s.Enqueue(&jobItem{username: "username4"})
items, queueLen := s.Dequeue(2)
require.Len(t, items, 2)
require.Equal(t, 3, queueLen)
require.Equal(t, "username1", items[0].Username)
require.Equal(t, "username2", items[1].Username)
items := s.Dequeue(2)
assert.Len(items, 2)
assert.Equal("username1", items[0].username)
assert.Equal("username2", items[1].username)
assert.Equal(2, s.Size())
items = s.Dequeue(40)
assert.Len(items, 2)
assert.Equal("username3", items[0].username)
assert.Equal("username4", items[1].username)
items, queueLen = s.Dequeue(40)
require.Len(t, items, 3)
require.Equal(t, 0, queueLen)
require.Equal(t, "username3", items[0].Username)
require.Equal(t, "username4", items[1].Username)
require.Equal(t, "username5", items[2].Username)
})
}
// This is really stupid test just to get 100% coverage on this package :)
func TestBatchUuidsProvider_forever(t *testing.T) {
testify.True(t, forever())
}
type mojangUsernamesToUuidsRequestMock struct {
mock.Mock
}
@@ -73,6 +60,37 @@ func (o *mojangUsernamesToUuidsRequestMock) UsernamesToUuids(usernames []string)
return result, args.Error(1)
}
type manualStrategy struct {
ch chan *JobsIteration
once sync.Once
lock sync.Mutex
jobs []*job
}
func (m *manualStrategy) Queue(job *job) {
m.lock.Lock()
m.jobs = append(m.jobs, job)
m.lock.Unlock()
}
func (m *manualStrategy) GetJobs(_ context.Context) <-chan *JobsIteration {
m.lock.Lock()
defer m.lock.Unlock()
m.ch = make(chan *JobsIteration)
return m.ch
}
func (m *manualStrategy) Iterate(countJobsToReturn int, countLeftJobsInQueue int) {
m.lock.Lock()
defer m.lock.Unlock()
m.ch <- &JobsIteration{
Jobs: m.jobs[0:countJobsToReturn],
Queue: countLeftJobsInQueue,
}
}
type batchUuidsProviderGetUuidResult struct {
Result *mojang.ProfileInfo
Error error
@@ -81,71 +99,54 @@ type batchUuidsProviderGetUuidResult struct {
type batchUuidsProviderTestSuite struct {
suite.Suite
Provider *BatchUuidsProvider
GetUuidAsync func(username string) chan *batchUuidsProviderGetUuidResult
Provider *BatchUuidsProvider
Emitter *mockEmitter
Strategy *manualStrategy
MojangApi *mojangUsernamesToUuidsRequestMock
Iterate func()
done func()
iterateChan chan bool
stop context.CancelFunc
}
func (suite *batchUuidsProviderTestSuite) GetUuidAsync(username string) <-chan *batchUuidsProviderGetUuidResult {
s := make(chan struct{})
// This dirty hack ensures, that the username will be queued before we return control to the caller.
// It's needed to keep expected calls order and prevent cases when iteration happens before
// all usernames will be queued.
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:queued",
username,
).Once().Run(func(args mock.Arguments) {
close(s)
})
c := make(chan *batchUuidsProviderGetUuidResult)
go func() {
profile, err := suite.Provider.GetUuid(username)
c <- &batchUuidsProviderGetUuidResult{
Result: profile,
Error: err,
}
}()
<-s
return c
}
func (suite *batchUuidsProviderTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.Provider = &BatchUuidsProvider{
Emitter: suite.Emitter,
IterationDelay: 0,
IterationSize: 10,
}
suite.iterateChan = make(chan bool)
forever = func() bool {
return <-suite.iterateChan
}
suite.Iterate = func() {
suite.iterateChan <- true
}
suite.done = func() {
suite.iterateChan <- false
}
suite.GetUuidAsync = func(username string) chan *batchUuidsProviderGetUuidResult {
s := make(chan bool)
// This dirty hack ensures, that the username will be queued before we return control to the caller.
// It's needed to keep expected calls order and prevent cases when iteration happens before all usernames
// will be queued.
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:queued",
username,
).Once().Run(func(args mock.Arguments) {
s <- true
})
c := make(chan *batchUuidsProviderGetUuidResult)
go func() {
profile, err := suite.Provider.GetUuid(username)
c <- &batchUuidsProviderGetUuidResult{
Result: profile,
Error: err,
}
}()
<-s
return c
}
suite.Strategy = &manualStrategy{}
ctx, stop := context.WithCancel(context.Background())
suite.stop = stop
suite.MojangApi = &mojangUsernamesToUuidsRequestMock{}
usernamesToUuids = suite.MojangApi.UsernamesToUuids
suite.Provider = NewBatchUuidsProvider(ctx, suite.Strategy, suite.Emitter)
}
func (suite *batchUuidsProviderTestSuite) TearDownTest() {
suite.done()
suite.stop()
suite.Emitter.AssertExpectations(suite.T())
suite.MojangApi.AssertExpectations(suite.T())
}
@@ -154,37 +155,14 @@ func TestBatchUuidsProvider(t *testing.T) {
suite.Run(t, new(batchUuidsProviderTestSuite))
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForOneUsername() {
expectedUsernames := []string{"username"}
expectedResult := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResponse := []*mojang.ProfileInfo{expectedResult}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{expectedResult}, nil)
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Equal(expectedResult, result.Result)
suite.Assert().Nil(result.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
func (suite *batchUuidsProviderTestSuite) TestGetUuidForFewUsernames() {
expectedUsernames := []string{"username1", "username2"}
expectedResult1 := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
expectedResult2 := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
expectedResponse := []*mojang.ProfileInfo{expectedResult1, expectedResult2}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{
expectedResult1,
@@ -194,7 +172,7 @@ func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Iterate()
suite.Strategy.Iterate(2, 0)
result1 := <-resultChan1
suite.Assert().Equal(expectedResult1, result1.Result)
@@ -205,78 +183,41 @@ func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
suite.Assert().Nil(result2.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForMoreThan10Usernames() {
usernames := make([]string, 12)
for i := 0; i < cap(usernames); i++ {
usernames[i] = randStr(8)
}
func (suite *batchUuidsProviderTestSuite) TestShouldNotSendRequestWhenNoJobsAreReturned() {
//noinspection GoPreferNilSlice
emptyUsernames := []string{}
done := make(chan struct{})
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:round",
emptyUsernames,
1,
).Once().Run(func(args mock.Arguments) {
close(done)
})
// In this test we're not testing response, so always return an empty resultset
expectedResponse := []*mojang.ProfileInfo{}
suite.GetUuidAsync("username") // Schedule one username to run the queue
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[0:10], 2).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", usernames[0:10], expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[10:12], 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", usernames[10:12], expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Twice()
suite.Strategy.Iterate(0, 1) // Return no jobs and indicate that there is one job in queue
suite.MojangApi.On("UsernamesToUuids", usernames[0:10]).Once().Return(expectedResponse, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[10:12]).Once().Return(expectedResponse, nil)
channels := make([]chan *batchUuidsProviderGetUuidResult, len(usernames))
for i, username := range usernames {
channels[i] = suite.GetUuidAsync(username)
}
suite.Iterate()
suite.Iterate()
for _, channel := range channels {
<-channel
}
<-done
}
func (suite *batchUuidsProviderTestSuite) TestDoNothingWhenNoTasks() {
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Times(3)
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", []string{"username"}, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", []string{"username"}, mock.Anything, nil).Once()
var nilStringSlice []string
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", nilStringSlice, 0).Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Times(3)
suite.MojangApi.On("UsernamesToUuids", []string{"username"}).Once().Return([]*mojang.ProfileInfo{}, nil)
// Perform first iteration and await it finishes
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Nil(result.Result)
suite.Assert().Nil(result.Error)
// Let it to perform a few more iterations to ensure, that there are no calls to external APIs
suite.Iterate()
suite.Iterate()
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernamesWithAnError() {
// Test written for multiple usernames to ensure that the error
// will be returned for each iteration group
func (suite *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesWithAnError() {
expectedUsernames := []string{"username1", "username2"}
expectedError := &mojang.TooManyRequestsError{}
var nilProfilesResponse []*mojang.ProfileInfo
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, nilProfilesResponse, expectedError).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return(nil, expectedError)
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Iterate()
suite.Strategy.Iterate(2, 0)
result1 := <-resultChan1
suite.Assert().Nil(result1.Result)
@@ -287,14 +228,213 @@ func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernamesWithAnError(
suite.Assert().Equal(expectedError, result2.Error)
}
var replacer = strings.NewReplacer("-", "_", "=", "")
func TestPeriodicStrategy(t *testing.T) {
t.Run("should return first job only after duration", func(t *testing.T) {
d := 20 * time.Millisecond
strategy := NewPeriodicStrategy(d, 10)
j := &job{}
strategy.Queue(j)
// https://stackoverflow.com/a/50581165
func randStr(len int) string {
buff := make([]byte, len)
_, _ = rand.Read(buff)
str := replacer.Replace(base64.URLEncoding.EncodeToString(buff))
ctx, cancel := context.WithCancel(context.Background())
startedAt := time.Now()
ch := strategy.GetJobs(ctx)
iteration := <-ch
durationBeforeResult := time.Now().Sub(startedAt)
require.True(t, durationBeforeResult >= d)
require.True(t, durationBeforeResult < d*2)
// Base 64 can be longer than len
return str[:len]
require.Equal(t, []*job{j}, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
cancel()
})
t.Run("should return the configured batch size", func(t *testing.T) {
strategy := NewPeriodicStrategy(0, 10)
jobs := make([]*job, 15)
for i := 0; i < 15; i++ {
jobs[i] = &job{Username: strconv.Itoa(i)}
strategy.Queue(jobs[i])
}
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
iteration := <-ch
require.Len(t, iteration.Jobs, 10)
require.Equal(t, jobs[0:10], iteration.Jobs)
require.Equal(t, 5, iteration.Queue)
cancel()
})
t.Run("should not return the next iteration until the previous one is finished", func(t *testing.T) {
strategy := NewPeriodicStrategy(0, 10)
strategy.Queue(&job{})
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
iteration := <-ch
require.Len(t, iteration.Jobs, 1)
require.Equal(t, 0, iteration.Queue)
time.Sleep(time.Millisecond) // Let strategy's internal loop to work (if the implementation is broken)
select {
case <-ch:
require.Fail(t, "the previous iteration isn't marked as done")
default:
// ok
}
iteration.Done()
time.Sleep(time.Millisecond) // Let strategy's internal loop to work
select {
case iteration = <-ch:
// ok
default:
require.Fail(t, "iteration should be provided")
}
require.Empty(t, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
iteration.Done()
cancel()
})
t.Run("each iteration should be returned only after the configured duration", func(t *testing.T) {
d := 5 * time.Millisecond
strategy := NewPeriodicStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
for i := 0; i < 3; i++ {
startedAt := time.Now()
iteration := <-ch
durationBeforeResult := time.Now().Sub(startedAt)
require.True(t, durationBeforeResult >= d)
require.True(t, durationBeforeResult < d*2)
require.Empty(t, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
// Sleep for at least doubled duration before calling Done() to check,
// that this duration isn't included into the next iteration time
time.Sleep(d * 2)
iteration.Done()
}
cancel()
})
}
func TestFullBusStrategy(t *testing.T) {
t.Run("should provide iteration immediately when the batch size exceeded", func(t *testing.T) {
jobs := make([]*job, 10)
for i := 0; i < 10; i++ {
jobs[i] = &job{}
}
d := 20 * time.Millisecond
strategy := NewFullBusStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
done := make(chan struct{})
go func() {
defer close(done)
select {
case iteration := <-ch:
require.Len(t, iteration.Jobs, 10)
require.Equal(t, 0, iteration.Queue)
case <-time.After(d):
require.Fail(t, "iteration should be provided immediately")
}
}()
for _, j := range jobs {
strategy.Queue(j)
}
<-done
cancel()
})
t.Run("should provide iteration after duration if batch size isn't exceeded", func(t *testing.T) {
jobs := make([]*job, 9)
for i := 0; i < 9; i++ {
jobs[i] = &job{}
}
d := 20 * time.Millisecond
strategy := NewFullBusStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
startedAt := time.Now()
ch := strategy.GetJobs(ctx)
done := make(chan struct{})
go func() {
defer close(done)
iteration := <-ch
duration := time.Now().Sub(startedAt)
require.True(t, duration >= d, fmt.Sprintf("has %d, expected %d", duration, d))
require.True(t, duration < d*2)
require.Equal(t, jobs, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
}()
for _, j := range jobs {
strategy.Queue(j)
}
<-done
cancel()
})
t.Run("should provide iteration as soon as the bus is full, without waiting for the previous iteration to finish", func(t *testing.T) {
d := 20 * time.Millisecond
strategy := NewFullBusStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 3; i++ {
time.Sleep(5 * time.Millisecond) // See comment below
select {
case iteration := <-ch:
require.Len(t, iteration.Jobs, 10)
// Don't assert iteration.Queue length since it might be unstable
// Don't call iteration.Done()
case <-time.After(d):
t.Fatalf("iteration should be provided as soon as the bus is full")
}
}
// Scheduled 31 tasks. 3 iterations should be performed immediately
// and should be executed only after timeout. The timeout above is used
// to increase overall time to ensure, that timer resets on every iteration
startedAt := time.Now()
iteration := <-ch
duration := time.Now().Sub(startedAt)
require.True(t, duration >= d)
require.True(t, duration < d*2)
require.Len(t, iteration.Jobs, 1)
require.Equal(t, 0, iteration.Queue)
}()
for i := 0; i < 31; i++ {
strategy.Queue(&job{})
}
<-done
cancel()
})
}

View File

@@ -5,12 +5,8 @@ import (
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/tevino/abool"
)
var now = time.Now
type inMemoryItem struct {
textures *mojang.SignedTexturesResponse
timestamp int64
@@ -20,9 +16,10 @@ type InMemoryTexturesStorage struct {
GCPeriod time.Duration
Duration time.Duration
lock sync.RWMutex
data map[string]*inMemoryItem
working *abool.AtomicBool
once sync.Once
lock sync.RWMutex
data map[string]*inMemoryItem
done chan struct{}
}
func NewInMemoryTexturesStorage() *InMemoryTexturesStorage {
@@ -35,30 +32,6 @@ func NewInMemoryTexturesStorage() *InMemoryTexturesStorage {
return storage
}
func (s *InMemoryTexturesStorage) Start() {
if s.working == nil {
s.working = abool.New()
}
if !s.working.IsSet() {
go func() {
time.Sleep(s.GCPeriod)
// TODO: this can be reimplemented in future with channels, but right now I have no idea how to make it right
for s.working.IsSet() {
start := time.Now()
s.gc()
time.Sleep(s.GCPeriod - time.Since(start))
}
}()
}
s.working.Set()
}
func (s *InMemoryTexturesStorage) Stop() {
s.working.UnSet()
}
func (s *InMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
s.lock.RLock()
defer s.lock.RUnlock()
@@ -66,34 +39,43 @@ func (s *InMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTextur
item, exists := s.data[uuid]
validRange := s.getMinimalNotExpiredTimestamp()
if !exists || validRange > item.timestamp {
return nil, &ValueNotFound{}
return nil, nil
}
return item.textures, nil
}
func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
var timestamp int64
if textures != nil {
decoded := textures.DecodeTextures()
if decoded == nil {
panic("unable to decode textures")
}
timestamp = decoded.Timestamp
} else {
timestamp = unixNanoToUnixMicro(now().UnixNano())
}
s.once.Do(s.start)
s.lock.Lock()
defer s.lock.Unlock()
s.data[uuid] = &inMemoryItem{
textures: textures,
timestamp: timestamp,
timestamp: unixNanoToUnixMicro(time.Now().UnixNano()),
}
}
func (s *InMemoryTexturesStorage) start() {
s.done = make(chan struct{})
ticker := time.NewTicker(s.GCPeriod)
go func() {
for {
select {
case <-s.done:
return
case <-ticker.C:
s.gc()
}
}
}()
}
func (s *InMemoryTexturesStorage) Stop() {
close(s.done)
}
func (s *InMemoryTexturesStorage) gc() {
s.lock.Lock()
defer s.lock.Unlock()
@@ -107,7 +89,7 @@ func (s *InMemoryTexturesStorage) gc() {
}
func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(now().Add(s.Duration * time.Duration(-1)).UnixNano())
return unixNanoToUnixMicro(time.Now().Add(s.Duration * time.Duration(-1)).UnixNano())
}
func unixNanoToUnixMicro(unixNano int64) int64 {

View File

@@ -1,12 +1,12 @@
package mojangtextures
import (
"testing"
"time"
"github.com/elyby/chrly/api/mojang"
assert "github.com/stretchr/testify/require"
testify "github.com/stretchr/testify/assert"
"testing"
"github.com/elyby/chrly/api/mojang"
)
var texturesWithSkin = &mojang.SignedTexturesResponse{
@@ -45,156 +45,120 @@ var texturesWithoutSkin = &mojang.SignedTexturesResponse{
}
func TestInMemoryTexturesStorage_GetTextures(t *testing.T) {
t.Run("get error when uuid is not exists", func(t *testing.T) {
assert := testify.New(t)
t.Run("should return nil, nil when textures are unavailable", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
assert.Nil(t, result)
assert.Nil(t, err)
})
t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
assert.Equal(t, texturesWithSkin, result)
assert.Nil(t, err)
})
t.Run("get error when uuid is exists, but textures are expired", func(t *testing.T) {
assert := testify.New(t)
t.Run("should return nil, nil when textures are exists, but cache duration is expired", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
storage.Duration = 10 * time.Millisecond
storage.GCPeriod = time.Minute
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
now = func() time.Time {
return time.Now().Add(time.Minute * 2)
}
time.Sleep(storage.Duration * 2)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
now = time.Now
assert.Nil(t, result)
assert.Nil(t, err)
})
}
func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
t.Run("store textures for previously not existed uuid", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
assert.Equal(t, texturesWithSkin, result)
assert.Nil(t, err)
})
t.Run("override already existed textures for uuid", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin)
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.NotEqual(texturesWithoutSkin, result)
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
assert.NotEqual(t, texturesWithoutSkin, result)
assert.Equal(t, texturesWithSkin, result)
assert.Nil(t, err)
})
t.Run("store nil textures", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Nil(err)
})
t.Run("should panic if textures prop is not decoded", func(t *testing.T) {
assert := testify.New(t)
toStore := &mojang.SignedTexturesResponse{
Id: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
t.Run("store textures with empty properties", func(t *testing.T) {
texturesWithEmptyProps := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{},
}
assert.PanicsWithValue("unable to decode textures", func() {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", toStore)
})
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithEmptyProps)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Exactly(t, texturesWithEmptyProps, result)
assert.Nil(t, err)
})
t.Run("store nil textures", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(t, result)
assert.Nil(t, err)
})
}
func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
defer storage.Stop()
storage.GCPeriod = 10 * time.Millisecond
storage.Duration = 10 * time.Millisecond
storage.Duration = 9 * time.Millisecond
textures1 := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock1",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(5)).UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock1",
Textures: &mojang.TexturesResponse{},
}),
},
},
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock1",
Props: []*mojang.Property{},
}
textures2 := &mojang.SignedTexturesResponse{
Id: "b5d58475007d4f9e9ddd1403e2497579",
Name: "mock2",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(15)).UnixNano() / 10e5,
ProfileID: "b5d58475007d4f9e9ddd1403e2497579",
ProfileName: "mock2",
Textures: &mojang.TexturesResponse{},
}),
},
},
Id: "b5d58475007d4f9e9ddd1403e2497579",
Name: "mock2",
Props: []*mojang.Property{},
}
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1)
// Store another texture a bit later to avoid it removing by GC after the first iteration
time.Sleep(2 * time.Millisecond)
storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2)
storage.Start()
storage.lock.RLock()
assert.Len(t, storage.data, 2, "the GC period has not yet reached")
storage.lock.RUnlock()
time.Sleep(storage.GCPeriod + time.Millisecond) // Let it start first iteration
time.Sleep(storage.GCPeriod) // Let it perform the first GC iteration
_, textures1Err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
storage.lock.RLock()
assert.Len(t, storage.data, 1, "the first texture should be cleaned by GC")
assert.Contains(t, storage.data, "b5d58475007d4f9e9ddd1403e2497579")
storage.lock.RUnlock()
assert.Nil(textures1Err)
assert.Error(textures2Err)
time.Sleep(storage.GCPeriod) // Let another iteration happen
time.Sleep(storage.GCPeriod + time.Millisecond) // Let another iteration happen
_, textures1Err = storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err = storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Error(textures1Err)
assert.Error(textures2Err)
storage.Stop()
storage.lock.RLock()
assert.Len(t, storage.data, 0)
storage.lock.RUnlock()
}

View File

@@ -13,7 +13,7 @@ type MojangApiTexturesProvider struct {
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:mojang_api_textures_provider:before_request", uuid)
result, err := uuidToTextures(uuid, true)
ctx.Emit("mojang_textures:mojang_api_textures_provider:after_request", result, err)
ctx.Emit("mojang_textures:mojang_api_textures_provider:after_request", uuid, result, err)
return result, err
}

View File

@@ -64,6 +64,7 @@ func (suite *mojangApiTexturesProviderTestSuite) TestGetTextures() {
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expectedResult,
nil,
).Once()
@@ -85,6 +86,7 @@ func (suite *mojangApiTexturesProviderTestSuite) TestGetTexturesWithError() {
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expectedResponse,
expectedError,
).Once()

View File

@@ -98,14 +98,18 @@ func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResp
username = strings.ToLower(username)
ctx.Emit("mojang_textures:call", username)
uuid, err := ctx.getUuidFromCache(username)
if err == nil && uuid == "" {
uuid, found, err := ctx.getUuidFromCache(username)
if err != nil {
return nil, err
}
if found && uuid == "" {
return nil, nil
}
if uuid != "" {
textures, err := ctx.getTexturesFromCache(uuid)
if err == nil {
if err == nil && textures != nil {
return textures, nil
}
}
@@ -162,12 +166,12 @@ func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
return &broadcastResult{textures, nil}
}
func (ctx *Provider) getUuidFromCache(username string) (string, error) {
func (ctx *Provider) getUuidFromCache(username string) (string, bool, error) {
ctx.Emit("mojang_textures:usernames:before_cache", username)
uuid, err := ctx.Storage.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, err)
uuid, found, err := ctx.Storage.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, found, err)
return uuid, err
return uuid, found, err
}
func (ctx *Provider) getTexturesFromCache(uuid string) (*mojang.SignedTexturesResponse, error) {

View File

@@ -122,9 +122,9 @@ type mockStorage struct {
mock.Mock
}
func (m *mockStorage) GetUuid(username string) (string, error) {
func (m *mockStorage) GetUuid(username string) (string, bool, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
return args.String(0), args.Bool(1), args.Error(2)
}
func (m *mockStorage) StoreUuid(username string, uuid string) error {
@@ -186,7 +186,7 @@ func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
@@ -194,7 +194,7 @@ func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
@@ -213,16 +213,16 @@ func (suite *providerTestSuite) TestGetForUsernameWithCachedUuid() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedCachedTextures, &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedCachedTextures, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(expectedResult, nil)
@@ -238,11 +238,11 @@ func (suite *providerTestSuite) TestGetForUsernameWithFullyCachedResult() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
@@ -254,9 +254,9 @@ func (suite *providerTestSuite) TestGetForUsernameWithFullyCachedResult() {
func (suite *providerTestSuite) TestGetForUsernameWithCachedUnknownUuid() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", true, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", nil)
suite.Storage.On("GetUuid", "username").Once().Return("", true, nil)
result, err := suite.Provider.GetForUsername("username")
@@ -270,13 +270,13 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "").Once().Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, nil)
@@ -293,7 +293,7 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoM
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
@@ -301,7 +301,7 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoM
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
@@ -320,7 +320,7 @@ func (suite *providerTestSuite) TestGetForTheSameUsernames() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Twice()
suite.Emitter.On("Emit", "mojang_textures:already_processing", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
@@ -329,7 +329,7 @@ func (suite *providerTestSuite) TestGetForTheSameUsernames() {
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Twice().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Twice().Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
@@ -359,6 +359,21 @@ func (suite *providerTestSuite) TestGetForNotAllowedMojangUsername() {
suite.Assert().Nil(result)
}
func (suite *providerTestSuite) TestGetErrorFromUUIDsStorage() {
expectedErr := errors.New("mock error")
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, expectedErr).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", false, expectedErr)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Equal(expectedErr, err)
}
func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
var expectedProfile *mojang.ProfileInfo
var expectedResult *mojang.SignedTexturesResponse
@@ -366,13 +381,13 @@ func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
result, resErr := suite.Provider.GetForUsername("username")
@@ -387,7 +402,7 @@ func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
@@ -395,7 +410,7 @@ func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)

View File

@@ -4,12 +4,13 @@ import (
"github.com/elyby/chrly/api/mojang"
)
// UuidsStorage is a key-value storage of Mojang usernames pairs to its UUIDs,
// UUIDsStorage is a key-value storage of Mojang usernames pairs to its UUIDs,
// used to reduce the load on the account information queue
type UuidsStorage interface {
// Since only primitive types are used in this method, you should return a special error ValueNotFound
// to return the information that no error has occurred and username does not have uuid
GetUuid(username string) (string, error)
type UUIDsStorage interface {
// The second argument indicates whether a record was found in the storage,
// since depending on it, the empty value must be interpreted as "no cached record"
// or "value cached and has an empty value"
GetUuid(username string) (uuid string, found bool, err error)
// An empty uuid value can be passed if the corresponding account has not been found
StoreUuid(username string, uuid string) error
}
@@ -24,23 +25,23 @@ type TexturesStorage interface {
}
type Storage interface {
UuidsStorage
UUIDsStorage
TexturesStorage
}
// SeparatedStorage allows you to use separate storage engines to satisfy
// the Storage interface
type SeparatedStorage struct {
UuidsStorage
UUIDsStorage
TexturesStorage
}
func (s *SeparatedStorage) GetUuid(username string) (string, error) {
return s.UuidsStorage.GetUuid(username)
func (s *SeparatedStorage) GetUuid(username string) (string, bool, error) {
return s.UUIDsStorage.GetUuid(username)
}
func (s *SeparatedStorage) StoreUuid(username string, uuid string) error {
return s.UuidsStorage.StoreUuid(username, uuid)
return s.UUIDsStorage.StoreUuid(username, uuid)
}
func (s *SeparatedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
@@ -50,12 +51,3 @@ func (s *SeparatedStorage) GetTextures(uuid string) (*mojang.SignedTexturesRespo
func (s *SeparatedStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
s.TexturesStorage.StoreTextures(uuid, textures)
}
// This error can be used to indicate, that requested
// value doesn't exists in the storage
type ValueNotFound struct {
}
func (*ValueNotFound) Error() string {
return "value not found in the storage"
}

View File

@@ -12,9 +12,9 @@ type uuidsStorageMock struct {
mock.Mock
}
func (m *uuidsStorageMock) GetUuid(username string) (string, error) {
func (m *uuidsStorageMock) GetUuid(username string) (string, bool, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
return args.String(0), args.Bool(1), args.Error(2)
}
func (m *uuidsStorageMock) StoreUuid(username string, uuid string) error {
@@ -50,9 +50,10 @@ func TestSplittedStorage(t *testing.T) {
t.Run("GetUuid", func(t *testing.T) {
storage, uuidsMock, _ := createMockedStorage()
uuidsMock.On("GetUuid", "username").Once().Return("find me", nil)
result, err := storage.GetUuid("username")
uuidsMock.On("GetUuid", "username").Once().Return("find me", true, nil)
result, found, err := storage.GetUuid("username")
assert.Nil(t, err)
assert.True(t, found)
assert.Equal(t, "find me", result)
uuidsMock.AssertExpectations(t)
})
@@ -82,8 +83,3 @@ func TestSplittedStorage(t *testing.T) {
texturesMock.AssertExpectations(t)
})
}
func TestValueNotFound_Error(t *testing.T) {
err := &ValueNotFound{}
assert.Equal(t, "value not found in the storage", err.Error())
}