Skip to content

Added KV Store client that mirrors one backend to another #1749

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 31 commits into from
Jan 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c951b43
Added new KV Store client, MultiClient.
pstibrany Oct 14, 2019
1b2f37a
Use Stop, which is now part of kv.Client.
pstibrany Nov 1, 2019
6dc84fc
Put back setting of defaultLimits -- used when loading YAML files.
pstibrany Nov 1, 2019
50c013c
Moved setting of default limits for YAML unmarshal to separate function.
pstibrany Nov 1, 2019
0fa6cb2
Pass multi-client context as argument.
pstibrany Nov 5, 2019
e2efe9e
watchConfigChannel now reacts on context being done as well
pstibrany Nov 5, 2019
56491b2
Changed Mirroring to *bool.
pstibrany Nov 5, 2019
364bc9c
Ignore mock by yaml.
pstibrany Nov 5, 2019
2d4f1c0
Renamed mirroring to mirror-enabled to be consistent with MultiConfig.
pstibrany Nov 6, 2019
9665933
Renamed 'multi' to 'multi_kv_config' in overrides.yaml.
pstibrany Nov 6, 2019
a3de2a8
Forward writes done via CAS function to secondary client.
pstibrany Nov 6, 2019
d91a5a7
Added metrics to multi client.
pstibrany Nov 6, 2019
43ec606
Removed equality check when writing to secondary store.
pstibrany Nov 6, 2019
9bbea9b
Renamed OverridesManager and moved it to its own package.
pstibrany Nov 18, 2019
8c2f557
Make lint happy, 3.
pstibrany Nov 26, 2019
d7f7f92
Make lint happy, 4.
pstibrany Nov 26, 2019
df3c6e0
Add metric type to variable names, yaml name changes, fixed metric na…
pstibrany Nov 26, 2019
103dbaf
Fixed yet one more yaml name.
pstibrany Nov 26, 2019
7de6ad8
Fixed tests after changing yaml fields.
pstibrany Nov 26, 2019
63d566f
Fix bug when default limits are not applied until next overrides reload.
pstibrany Nov 28, 2019
666e17e
Ignore LoadConfig if LoadPath is empty.
pstibrany Nov 28, 2019
c83361b
Use channels to communicate config updates.
pstibrany Nov 28, 2019
5714d21
Initialize limits before starting runtimeconfig Manager.
pstibrany Nov 28, 2019
ecfd16d
Updated CHANGELOG.md and arguments.md.
pstibrany Dec 5, 2019
e5ba1db
Typo
pstibrany Dec 5, 2019
7245d4d
Fix compilation error in ingester_v2_test.go.
pstibrany Dec 5, 2019
b16a010
Fixed error after rebase.
pstibrany Dec 13, 2019
45237f8
Fixed error after rebase.
pstibrany Dec 13, 2019
ff24854
Use logger with component="multikv" to log messages.
pstibrany Jan 7, 2020
713619a
Improve log message when runtime config file is not specified.
pstibrany Jan 7, 2020
cc476c6
Don't use memberlist in the example, as it is still experimental.
pstibrany Jan 7, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
* [CHANGE] Use relative links from /ring page to make it work when used behind reverse proxy. #1896
* [CHANGE] Deprecated `-distributor.limiter-reload-period` flag. #1766
* [CHANGE] Ingesters now write only normalised tokens to the ring, although they can still read denormalised tokens used by other ingesters. `-ingester.normalise-tokens` is now deprecated, and ignored. If you want to switch back to using denormalised tokens, you need to downgrade to Cortex 0.4.0. Previous versions don't handle claiming tokens from normalised ingesters correctly. #1809
* [CHANGE] Overrides mechanism has been renamed to "runtime config", and is now separate from limits. Runtime config is simply a file that is reloaded by Cortex every couple of seconds. Limits and now also multi KV use this mechanism.<br />New arguments were introduced: `-runtime-config.file` (defaults to empty) and `-runtime-config.reload-period` (defaults to 10 seconds), which replace previously used `-limits.per-user-override-config` and `-limits.per-user-override-period` options. Old options are still used if `-runtime-config.file` is not specified. This change is also reflected in YAML configuration, where old `limits.per_tenant_override_config` and `limits.per_tenant_override_period` fields are replaced with `runtime_config.file` and `runtime_config.period` respectively. #1749
* [FEATURE] The distributor can now drop labels from samples (similar to the removal of the replica label for HA ingestion) per user via the `distributor.drop-label` flag. #1726
* [FEATURE] Added `global` ingestion rate limiter strategy. Deprecated `-distributor.limiter-reload-period` flag. #1766
* [FEATURE] Added support for Microsoft Azure blob storage to be used for storing chunk data. #1913
* [FEATURE] Added readiness probe endpoint`/ready` to queriers. #1934
* [FEATURE] EXPERIMENTAL: Added `/series` API endpoint support with TSDB blocks storage. #1830
* [FEATURE] Added "multi" KV store that can interact with two other KV stores, primary one for all reads and writes, and secondary one, which only receives writes. Primary/secondary store can be modified in runtime via runtime-config mechanism (previously "overrides"). #1749
* [ENHANCEMENT] Added `password` and `enable_tls` options to redis cache configuration. Enables usage of Microsoft Azure Cache for Redis service.
* [BUGFIX] Fixed unnecessary CAS operations done by the HA tracker when the jitter is enabled. #1861
* [BUGFIX] Fixed #1904 ingesters getting stuck in a LEAVING state after coming up from an ungraceful exit. #1921
Expand Down
63 changes: 58 additions & 5 deletions docs/configuration/arguments.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ The KVStore client is used by both the Ring and HA Tracker.
- `{ring,distributor.ha-tracker}.prefix`
The prefix for the keys in the store. Should end with a /. For example with a prefix of foo/, the key bar would be stored under foo/bar.
- `{ring,distributor.ha-tracker}.store`
Backend storage to use for the ring (consul, etcd, inmemory).
Backend storage to use for the ring (consul, etcd, inmemory, memberlist, multi).

#### Consul

Expand Down Expand Up @@ -182,6 +182,35 @@ Flags for configuring KV store based on memberlist library. This feature is expe
Timeout for writing 'packet' data.
- `memberlist.transport-debug`
Log debug transport messages. Note: global log.level must be at debug level as well.

#### Multi KV

This is a special key-value implementation that uses two different KV stores (eg. consul, etcd or memberlist). One of them is always marked as primary, and all reads and writes go to primary store. Other one, secondary, is only used for writes. The idea is that operator can use multi KV store to migrate from primary to secondary store in runtime.

For example, migration from Consul to Etcd would look like this:

- Set `ring.store` to use `multi` store. Set `-multi.primary=consul` and `-multi.secondary=etcd`. All consul and etcd settings must still be specified.
- Start all Cortex microservices. They will still use Consul as primary KV, but they will also write share ring via etcd.
- Operator can now use "runtime config" mechanism to switch primary store to etcd.
- After all Cortex microservices have picked up new primary store, and everything looks correct, operator can now shut down Consul, and modify Cortex configuration to use `-ring.store=etcd` only.
- At this point, Consul can be shut down.

Multi KV has following parameters:

- `multi.primary` - name of primary KV store. Same values as in `ring.store` are supported, except `multi`.
- `multi.secondary` - name of secondary KV store.
- `multi.mirror-enabled` - enable mirroring of values to secondary store, defaults to true
- `multi.mirror-timeout` - wait max this time to write to secondary store to finish. Default to 2 seconds. Errors writing to secondary store are not reported to caller, but are logged and also reported via `cortex_multikv_mirror_write_errors_total` metric.

Multi KV also reacts on changes done via runtime configuration. It uses this section:

```yaml
multi_kv_config:
mirror-enabled: false
primary: memberlist
```

Note that runtime configuration values take precedence over command line options.

### HA Tracker

Expand Down Expand Up @@ -276,11 +305,13 @@ It also talks to a KVStore and has it's own copies of the same flags used by the
Where you don't want to cache every chunk written by ingesters, but you do want to take advantage of chunk write deduplication, this option will make ingesters write a placeholder to the cache for each chunk.
Make sure you configure ingesters with a different cache to queriers, which need the whole value.

## Ingester, Distributor & Querier limits.
## Runtime Configuration file

Cortex implements various limits on the requests it can process, in order to prevent a single tenant overwhelming the cluster. There are various default global limits which apply to all tenants which can be set on the command line. These limits can also be overridden on a per-tenant basis, using a configuration file. Specify the filename for the override configuration file using the `-limits.per-user-override-config=<filename>` flag. The override file will be re-read every 10 seconds by default - this can also be controlled using the `-limits.per-user-override-period=10s` flag.
Cortex has a concept of "runtime config" file, which is simply a file that is reloaded while Cortex is running. It is used by some Cortex components to allow operator to change some aspects of Cortex configuration without restarting it. File is specified by using `-runtime-config.file=<filename>` flag and reload period (which defaults to 10 seconds) can be changed by `-runtime-config.reload-period=<duration>` flag. Previously this mechanism was only used by limits overrides, and flags were called `-limits.per-user-override-config=<filename>` and `-limits.per-user-override-period=10s` respectively. These are still used, if `-runtime-config.file=<filename>` is not specified.

The override file should be in YAML format and contain a single `overrides` field, which itself is a map of tenant ID (same values as passed in the `X-Scope-OrgID` header) to the various limits. An example `overrides.yml` could look like:
At the moment, two components use runtime configuration: limits and multi KV store.

Example runtime configuration file:

```yaml
overrides:
Expand All @@ -292,11 +323,33 @@ overrides:
max_samples_per_query: 1000000
max_series_per_metric: 100000
max_series_per_query: 100000

multi_kv_config:
mirror-enabled: false
primary: memberlist
```

When running Cortex on Kubernetes, store this file in a config map and mount it in each services' containers. When changing the values there is no need to restart the services, unless otherwise specified.

Valid fields are (with their corresponding flags for default values):
## Ingester, Distributor & Querier limits.

Cortex implements various limits on the requests it can process, in order to prevent a single tenant overwhelming the cluster. There are various default global limits which apply to all tenants which can be set on the command line. These limits can also be overridden on a per-tenant basis by using `overrides` field of runtime configuration file.

The `overrides` field is a map of tenant ID (same values as passed in the `X-Scope-OrgID` header) to the various limits. An example could look like:

```yaml
overrides:
tenant1:
ingestion_rate: 10000
max_series_per_metric: 100000
max_series_per_query: 100000
tenant2:
max_samples_per_query: 1000000
max_series_per_metric: 100000
max_series_per_query: 100000
```

Valid per-tenant limits are (with their corresponding flags for default values):

- `ingestion_rate_strategy` / `-distributor.ingestion-rate-limit-strategy`
- `ingestion_rate` / `-distributor.ingestion-rate-limit`
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ require (
github.com/thanos-io/thanos v0.8.1-0.20200102143048-a37ac093a67a
github.com/tinylib/msgp v0.0.0-20161221055906-38a6f61a768d // indirect
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 // indirect
github.com/uber-go/atomic v1.4.0
github.com/uber/jaeger-client-go v2.20.1+incompatible
github.com/weaveworks/billing-client v0.0.0-20171006123215-be0d55e547b1
github.com/weaveworks/common v0.0.0-20190822150010-afb9996716e4
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 h1:LnC5Kc/wtumK+WB441p7ynQJzVuNRJiqddSIE3IlSEQ=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/uber-go/atomic v1.4.0 h1:yOuPqEq4ovnhEjpHmfFwsqBXDYbQeT6Nb0bwD6XnD5o=
github.com/uber-go/atomic v1.4.0/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g=
github.com/uber/jaeger-client-go v2.20.1+incompatible h1:HgqpYBng0n7tLJIlyT4kPCIv5XgCsF+kai1NnnrJzEU=
github.com/uber/jaeger-client-go v2.20.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
github.com/uber/jaeger-lib v2.2.0+incompatible h1:MxZXOiR2JuoANZ3J6DE/U0kSFv/eJ/GfSYVCjK7dyaw=
Expand Down
2 changes: 1 addition & 1 deletion pkg/chunk/chunk_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func newTestChunkStoreConfig(t require.TestingT, schemaName string, storeCfg Sto
var limits validation.Limits
flagext.DefaultValues(&limits)
limits.MaxQueryLength = 30 * 24 * time.Hour
overrides, err := validation.NewOverrides(limits)
overrides, err := validation.NewOverrides(limits, nil)
require.NoError(t, err)

store := NewCompositeStore()
Expand Down
2 changes: 1 addition & 1 deletion pkg/chunk/storage/caching_fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ func defaultLimits() (*validation.Overrides, error) {
var defaults validation.Limits
flagext.DefaultValues(&defaults)
defaults.CardinalityLimit = 5
return validation.NewOverrides(defaults)
return validation.NewOverrides(defaults, nil)
}
2 changes: 1 addition & 1 deletion pkg/chunk/storage/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestFactoryStop(t *testing.T) {
},
}

limits, err := validation.NewOverrides(defaults)
limits, err := validation.NewOverrides(defaults, nil)
require.NoError(t, err)

store, err := NewStore(cfg, storeConfig, schemaConfig, limits)
Expand Down
32 changes: 18 additions & 14 deletions pkg/cortex/cortex.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/cortexproject/cortex/pkg/ruler"
"github.com/cortexproject/cortex/pkg/storage/tsdb"
"github.com/cortexproject/cortex/pkg/util"
"github.com/cortexproject/cortex/pkg/util/runtimeconfig"
"github.com/cortexproject/cortex/pkg/util/validation"
)

Expand Down Expand Up @@ -75,10 +76,11 @@ type Config struct {
Encoding encoding.Config `yaml:"-"` // No yaml for this, it only works with flags.
TSDB tsdb.Config `yaml:"tsdb"`

Ruler ruler.Config `yaml:"ruler,omitempty"`
ConfigDB db.Config `yaml:"configdb,omitempty"`
ConfigStore config_client.Config `yaml:"config_store,omitempty"`
Alertmanager alertmanager.MultitenantAlertmanagerConfig `yaml:"alertmanager,omitempty"`
Ruler ruler.Config `yaml:"ruler,omitempty"`
ConfigDB db.Config `yaml:"configdb,omitempty"`
ConfigStore config_client.Config `yaml:"config_store,omitempty"`
Alertmanager alertmanager.MultitenantAlertmanagerConfig `yaml:"alertmanager,omitempty"`
RuntimeConfig runtimeconfig.ManagerConfig `yaml:"runtime_config,omitempty"`
}

// RegisterFlags registers flag.
Expand Down Expand Up @@ -112,6 +114,7 @@ func (c *Config) RegisterFlags(f *flag.FlagSet) {
c.ConfigDB.RegisterFlags(f)
c.ConfigStore.RegisterFlagsWithPrefix("alertmanager.", f)
c.Alertmanager.RegisterFlags(f)
c.RuntimeConfig.RegisterFlags(f)

// These don't seem to have a home.
flag.IntVar(&chunk_util.QueryParallelism, "querier.query-parallelism", 100, "Max subqueries run in parallel per higher-level query.")
Expand Down Expand Up @@ -146,16 +149,17 @@ type Cortex struct {
target moduleName
httpAuthMiddleware middleware.Interface

server *server.Server
ring *ring.Ring
overrides *validation.Overrides
distributor *distributor.Distributor
ingester *ingester.Ingester
store chunk.Store
worker frontend.Worker
frontend *frontend.Frontend
tableManager *chunk.TableManager
cache cache.Cache
server *server.Server
ring *ring.Ring
overrides *validation.Overrides
distributor *distributor.Distributor
ingester *ingester.Ingester
store chunk.Store
worker frontend.Worker
frontend *frontend.Frontend
tableManager *chunk.TableManager
cache cache.Cache
runtimeConfig *runtimeconfig.Manager

ruler *ruler.Ruler
configAPI *api.API
Expand Down
39 changes: 32 additions & 7 deletions pkg/cortex/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/cortexproject/cortex/pkg/ring"
"github.com/cortexproject/cortex/pkg/ruler"
"github.com/cortexproject/cortex/pkg/util"
"github.com/cortexproject/cortex/pkg/util/runtimeconfig"
"github.com/cortexproject/cortex/pkg/util/validation"
)

Expand All @@ -40,6 +41,7 @@ type moduleName int
// The various modules that make up Cortex.
const (
Ring moduleName = iota
RuntimeConfig
Overrides
Server
Distributor
Expand All @@ -58,6 +60,8 @@ func (m moduleName) String() string {
switch m {
case Ring:
return "ring"
case RuntimeConfig:
return "runtime-config"
case Overrides:
return "overrides"
case Server:
Expand Down Expand Up @@ -152,6 +156,7 @@ func (t *Cortex) stopServer() (err error) {
}

func (t *Cortex) initRing(cfg *Config) (err error) {
cfg.Ingester.LifecyclerConfig.RingConfig.KVStore.Multi.ConfigProvider = multiClientRuntimeConfigChannel(t.runtimeConfig)
t.ring, err = ring.New(cfg.Ingester.LifecyclerConfig.RingConfig, "ingester", ring.IngesterRingKey)
if err != nil {
return
Expand All @@ -161,16 +166,30 @@ func (t *Cortex) initRing(cfg *Config) (err error) {
return
}

func (t *Cortex) initOverrides(cfg *Config) (err error) {
t.overrides, err = validation.NewOverrides(cfg.LimitsConfig)
func (t *Cortex) initRuntimeConfig(cfg *Config) (err error) {
if cfg.RuntimeConfig.LoadPath == "" {
cfg.RuntimeConfig.LoadPath = cfg.LimitsConfig.PerTenantOverrideConfig
cfg.RuntimeConfig.ReloadPeriod = cfg.LimitsConfig.PerTenantOverridePeriod
}
cfg.RuntimeConfig.Loader = loadRuntimeConfig

// make sure to set default limits before we start loading configuration into memory
validation.SetDefaultLimitsForYAMLUnmarshalling(cfg.LimitsConfig)

t.runtimeConfig, err = runtimeconfig.NewRuntimeConfigManager(cfg.RuntimeConfig)
return err
}

func (t *Cortex) stopOverrides() error {
t.overrides.Stop()
func (t *Cortex) stopRuntimeConfig() (err error) {
t.runtimeConfig.Stop()
return nil
}

func (t *Cortex) initOverrides(cfg *Config) (err error) {
t.overrides, err = validation.NewOverrides(cfg.LimitsConfig, tenantLimitsFromRuntimeConfig(t.runtimeConfig))
return err
}

func (t *Cortex) initDistributor(cfg *Config) (err error) {
cfg.Distributor.DistributorRing.ListenPort = cfg.Server.GRPCListenPort

Expand Down Expand Up @@ -257,6 +276,7 @@ func (t *Cortex) stopQuerier() error {
}

func (t *Cortex) initIngester(cfg *Config) (err error) {
cfg.Ingester.LifecyclerConfig.RingConfig.KVStore.Multi.ConfigProvider = multiClientRuntimeConfigChannel(t.runtimeConfig)
cfg.Ingester.LifecyclerConfig.ListenPort = &cfg.Server.GRPCListenPort
cfg.Ingester.TSDBEnabled = cfg.Storage.Engine == storage.StorageEngineTSDB
cfg.Ingester.TSDBConfig = cfg.TSDB
Expand Down Expand Up @@ -446,14 +466,19 @@ var modules = map[moduleName]module{
stop: (*Cortex).stopServer,
},

RuntimeConfig: {
init: (*Cortex).initRuntimeConfig,
stop: (*Cortex).stopRuntimeConfig,
},

Ring: {
deps: []moduleName{Server},
deps: []moduleName{Server, RuntimeConfig},
init: (*Cortex).initRing,
},

Overrides: {
deps: []moduleName{RuntimeConfig},
init: (*Cortex).initOverrides,
stop: (*Cortex).stopOverrides,
},

Distributor: {
Expand All @@ -469,7 +494,7 @@ var modules = map[moduleName]module{
},

Ingester: {
deps: []moduleName{Overrides, Store, Server},
deps: []moduleName{Overrides, Store, Server, RuntimeConfig},
init: (*Cortex).initIngester,
stop: (*Cortex).stopIngester,
},
Expand Down
72 changes: 72 additions & 0 deletions pkg/cortex/runtime_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package cortex

import (
"os"

"gopkg.in/yaml.v2"

"github.com/cortexproject/cortex/pkg/ring/kv"
"github.com/cortexproject/cortex/pkg/util/runtimeconfig"
"github.com/cortexproject/cortex/pkg/util/validation"
)

// runtimeConfigValues are values that can be reloaded from configuration file while Cortex is running.
// Reloading is done by runtime_config.Manager, which also keeps the currently loaded config.
// These values are then pushed to the components that are interested in them.
type runtimeConfigValues struct {
TenantLimits map[string]*validation.Limits `yaml:"overrides"`

Multi kv.MultiRuntimeConfig `yaml:"multi_kv_config"`
}

func loadRuntimeConfig(filename string) (interface{}, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}

var overrides = &runtimeConfigValues{}

decoder := yaml.NewDecoder(f)
decoder.SetStrict(true)
if err := decoder.Decode(&overrides); err != nil {
return nil, err
}

return overrides, nil
}

func tenantLimitsFromRuntimeConfig(c *runtimeconfig.Manager) validation.TenantLimits {
return func(userID string) *validation.Limits {
cfg, ok := c.GetConfig().(*runtimeConfigValues)
if !ok || cfg == nil {
return nil
}

return cfg.TenantLimits[userID]
}
}

func multiClientRuntimeConfigChannel(manager *runtimeconfig.Manager) func() <-chan kv.MultiRuntimeConfig {
// returns function that can be used in MultiConfig.ConfigProvider
return func() <-chan kv.MultiRuntimeConfig {
outCh := make(chan kv.MultiRuntimeConfig, 1)

// push initial config to the channel
val := manager.GetConfig()
if cfg, ok := val.(*runtimeConfigValues); ok && cfg != nil {
outCh <- cfg.Multi
}

ch := manager.CreateListenerChannel(1)
go func() {
for val := range ch {
if cfg, ok := val.(*runtimeConfigValues); ok && cfg != nil {
outCh <- cfg.Multi
}
}
}()

return outCh
}
}
Loading