レタスのかわをぜんぶむく

ぜんぶむきます

Redashでクエリにタイムアウトを設定する

要約

  • Redashのクエリ実行にはタイムアウトを設定できる
    • 手動実行 : REDASH_ADHOC_QUERY_TIME_LIMIT (v3.0.0~)
    • 定期実行 : REDASH_SCHEDULED_QUERY_TIME_LIMIT (v8.0.0~)
  • 本家ドキュメント側の記載が漏れていたのでprを出した

環境

  • macOS 10.15.5
  • docker desktop community 2.4.0.0 (48506)
  • Redash 8.0.0

手順

github.com

例によって、カック先生(id:kakku22)のredash-hands-onを利用する。
ただ、今回はクエリのタイムアウトについて検証を行いたいため下記について編集する。

環境変数の設定

docker-compose.ymlを参照すると分かるがRedash向けの環境変数がenvファイルにまとめられている。
今回追加する設定値はすべてRedashに関係する環境変数なのでここに追加すればOK。

追加するのは下記の3行

REDASH_ADDITIONAL_QUERY_RUNNERS=redash.query_runner.python
REDASH_ADHOC_QUERY_TIME_LIMIT=5
REDASH_SCHEDULED_QUERY_TIME_LIMIT=10

1行目でデータソースにPythonを追加し
2行目,3行目でそれぞれ手動実行, 定期実行クエリのタイムアウトを設定している。

起動時の設定

README.mdにはdocker-compose up -dと記載されているが
今回は実行時に各コンテナが出力するログを確認したいのでdocker-compose upと実行する。

データソースの追加

初回ログインまでチュートリアルにそって確認後はデータソースにPythonを追加する。
Modules to import prior to running the scriptには、datetime, timeを追加する必要がある。
(前に書いた記事で少し触れたが、RedashのPythonには何かと制限が多い)

実行するクエリは以下

import time
from datetime import datetime, timedelta
  
td_jst = timedelta(hours=9)
sleep  = 4
  
print("start")
time.sleep(sleep)
result = {}
add_result_column(result, 'finished_at', '', 'string')
add_result_row(
    result,
    {'finished_at' : (datetime.now() + td_jst).strftime('%Y-%m-%d %H:%M:%S')}
)
print("finish")

実行すると下記のような結果が得られた

クエリの実行時間からもsleepが効いていることが確認できる。

ADHOC_QUERY

まずは手動実行クエリのタイムアウト動作を確認する。
手動実行クエリのタイムアウトは5秒なのでsleepを6秒に設定して実行する。
実行後ブラウザには下記のようなエラーが表示されクエリが中断されたことが分かる。

同じタイミングのdocker-compose側のログは下記。 adhoc_worker_1で例外が発生していることがわかる。

...
adhoc_worker_1      | [2020-10-23 02:59:45,953][PID:1][WARNING][MainProcess] Soft time limit (5s) exceeded for redash.tasks.execute_query[6a76c71c-5a52-4605-b27a-ae5d570e617b]
adhoc_worker_1      | [2020-10-23 02:59:45,954][PID:24][INFO][ForkPoolWorker-3] task_name=redash.tasks.execute_query task_id=6a76c71c-5a52-4605-b27a-ae5d570e617b task=execute_query query_hash=60a9cad8d5384755b62d1971f9d914f0 data_length=None error=[<class 'billiard.exceptions.SoftTimeLimitExceeded'> SoftTimeLimitExceeded()]
adhoc_worker_1      | [2020-10-23 02:59:45,957][PID:24][ERROR][ForkPoolWorker-3] Task redash.tasks.execute_query[6a76c71c-5a52-4605-b27a-ae5d570e617b] raised unexpected: QueryExecutionError("<class 'billiard.exceptions.SoftTimeLimitExceeded'> SoftTimeLimitExceeded()",)
adhoc_worker_1      | Traceback (most recent call last):
adhoc_worker_1      |   File "/usr/local/lib/python2.7/site-packages/celery/app/trace.py", line 385, in trace_task
adhoc_worker_1      |     R = retval = fun(*args, **kwargs)
adhoc_worker_1      |   File "/app/redash/worker.py", line 84, in __call__
adhoc_worker_1      |     return TaskBase.__call__(self, *args, **kwargs)
adhoc_worker_1      |   File "/usr/local/lib/python2.7/site-packages/celery/app/trace.py", line 648, in __protected_call__
adhoc_worker_1      |     return self.run(*args, **kwargs)
adhoc_worker_1      |   File "/app/redash/tasks/queries.py", line 436, in execute_query
adhoc_worker_1      |     scheduled_query).run()
adhoc_worker_1      |   File "/app/redash/tasks/queries.py", line 382, in run
adhoc_worker_1      |     raise result
adhoc_worker_1      | QueryExecutionError: <class 'billiard.exceptions.SoftTimeLimitExceeded'> SoftTimeLimitExceeded()
...

SCHEDULED_QUERY

次には定期実行クエリの動作を確認する。
画面左下のRefresh Scheduleから定期実行の頻度を設定できる。
まずは設定時間が6秒のまま定期実行クエリのインターバルを毎分に設定して動作することを確認する。

...
server_1            | [2020-10-23 02:59:46,163][PID:13][INFO][metrics] method=GET path=/api/jobs/6a76c71c-5a52-4605-b27a-ae5d570e617b endpoint=job status=200 content_type=application/json content_length=198 duration=1.07 query_count=2 query_duration=2.51
nginx_1             | 172.23.0.1 - - [23/Oct/2020:02:59:46 +0000] "GET /api/jobs/6a76c71c-5a52-4605-b27a-ae5d570e617b HTTP/1.1" 200 183 "http://localhost/queries/1/source" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36" "-"
scheduler_1         | [2020-10-23 03:00:00,308][PID:22][INFO][Beat] Scheduler: Sending due task refresh_queries (redash.tasks.refresh_queries)
scheduler_1         | [2020-10-23 03:00:00,310][PID:1][INFO][MainProcess] Received task: redash.tasks.refresh_queries[543a01a0-ec5e-4708-b191-1f171619d3e5]
scheduler_1         | [2020-10-23 03:00:00,311][PID:33][INFO][ForkPoolWorker-12] task_name=redash.tasks.refresh_queries task_id=543a01a0-ec5e-4708-b191-1f171619d3e5 Refreshing queries...
scheduler_1         | [2020-10-23 03:00:00,315][PID:33][INFO][ForkPoolWorker-12] task_name=redash.tasks.refresh_queries task_id=543a01a0-ec5e-4708-b191-1f171619d3e5 Done refreshing queries. Found 0 outdated queries: []
scheduler_1         | [2020-10-23 03:00:00,317][PID:33][INFO][ForkPoolWorker-12] Task redash.tasks.refresh_queries[543a01a0-ec5e-4708-b191-1f171619d3e5] succeeded in 0.0063627999989s: None
scheduler_1         | [2020-10-23 03:00:30,305][PID:22][INFO][Beat] Scheduler: Sending due task sync_user_details (redash.tasks.sync_user_details)
scheduler_1         | [2020-10-23 03:00:30,307][PID:1][INFO][MainProcess] Received task: redash.tasks.sync_user_details[e830e682-bb32-4266-b87f-bd84183f1a2c]   expires:[2020-10-23 03:01:15.305506+00:00]
scheduler_1         | [2020-10-23 03:00:30,308][PID:22][INFO][Beat] Scheduler: Sending due task refresh_queries (redash.tasks.refresh_queries)
scheduler_1         | [2020-10-23 03:00:30,317][PID:33][INFO][ForkPoolWorker-12] Task redash.tasks.sync_user_details[e830e682-bb32-4266-b87f-bd84183f1a2c] succeeded in 0.00900940000065s: None
scheduler_1         | [2020-10-23 03:00:30,319][PID:1][INFO][MainProcess] Received task: redash.tasks.refresh_queries[81cb8e71-918d-4fa5-8950-03fe44bcfc31]
scheduler_1         | [2020-10-23 03:00:30,320][PID:33][INFO][ForkPoolWorker-12] task_name=redash.tasks.refresh_queries task_id=81cb8e71-918d-4fa5-8950-03fe44bcfc31 Refreshing queries...
scheduler_1         | [2020-10-23 03:00:30,330][PID:33][INFO][ForkPoolWorker-12] Inserting job for 60a9cad8d5384755b62d1971f9d914f0 with metadata={'Username': 'Scheduled', 'Query ID': 1}
scheduler_1         | [2020-10-23 03:00:30,347][PID:33][INFO][ForkPoolWorker-12] [60a9cad8d5384755b62d1971f9d914f0] Created new job: 255c632f-b198-4018-883c-bc295996bb7b
scheduled_worker_1  | [2020-10-23 03:00:30,347][PID:1][INFO][MainProcess] Received task: redash.tasks.execute_query[255c632f-b198-4018-883c-bc295996bb7b]
scheduler_1         | [2020-10-23 03:00:30,348][PID:33][INFO][ForkPoolWorker-12] task_name=redash.tasks.refresh_queries task_id=81cb8e71-918d-4fa5-8950-03fe44bcfc31 Done refreshing queries. Found 1 outdated queries: [1]
scheduled_worker_1  | [2020-10-23 03:00:30,352][PID:22][INFO][ForkPoolWorker-1] task_name=redash.tasks.execute_query task_id=255c632f-b198-4018-883c-bc295996bb7b task=execute_query state=load_ds ds_id=1
scheduler_1         | [2020-10-23 03:00:30,353][PID:33][INFO][ForkPoolWorker-12] Task redash.tasks.refresh_queries[81cb8e71-918d-4fa5-8950-03fe44bcfc31] succeeded in 0.0330446000007s: None
scheduled_worker_1  | [2020-10-23 03:00:30,356][PID:22][INFO][ForkPoolWorker-1] task_name=redash.tasks.execute_query task_id=255c632f-b198-4018-883c-bc295996bb7b task=execute_query state=executing_query query_hash=60a9cad8d5384755b62d1971f9d914f0 type=python ds_id=1  task_id=255c632f-b198-4018-883c-bc295996bb7b queue=scheduled_queries query_id=1 username=Scheduled
scheduled_worker_1  | [2020-10-23 03:00:36,366][PID:22][INFO][ForkPoolWorker-1] task_name=redash.tasks.execute_query task_id=255c632f-b198-4018-883c-bc295996bb7b task=execute_query query_hash=60a9cad8d5384755b62d1971f9d914f0 data_length=213 error=[None]
scheduled_worker_1  | [2020-10-23 03:00:36,368][PID:22][INFO][ForkPoolWorker-1] Inserted query (60a9cad8d5384755b62d1971f9d914f0) data; id=None
scheduled_worker_1  | [2020-10-23 03:00:36,378][PID:22][INFO][ForkPoolWorker-1] Updated 1 queries with result (60a9cad8d5384755b62d1971f9d914f0).
scheduled_worker_1  | [2020-10-23 03:00:36,380][PID:22][INFO][ForkPoolWorker-1] task_name=redash.tasks.execute_query task_id=255c632f-b198-4018-883c-bc295996bb7b task=execute_query state=checking_alerts query_hash=60a9cad8d5384755b62d1971f9d914f0 type=python ds_id=1  task_id=255c632f-b198-4018-883c-bc295996bb7b queue=scheduled_queries query_id=1 username=Scheduled
scheduled_worker_1  | [2020-10-23 03:00:36,382][PID:22][INFO][ForkPoolWorker-1] task_name=redash.tasks.execute_query task_id=255c632f-b198-4018-883c-bc295996bb7b task=execute_query state=finished query_hash=60a9cad8d5384755b62d1971f9d914f0 type=python ds_id=1  task_id=255c632f-b198-4018-883c-bc295996bb7b queue=scheduled_queries query_id=1 username=Scheduled
scheduler_1         | [2020-10-23 03:00:36,382][PID:1][INFO][MainProcess] Received task: redash.tasks.check_alerts_for_query[e25e7ceb-4d64-4cc6-9925-93d476cc9b97]
scheduled_worker_1  | [2020-10-23 03:00:36,383][PID:22][INFO][ForkPoolWorker-1] Task redash.tasks.execute_query[255c632f-b198-4018-883c-bc295996bb7b] succeeded in 6.0339485s: 230
...

最後の行の succeeded in 6.0339485s から分かる通り問題なく動作しているようだ。

次にタイムアウトの10秒を超えるようにsleepを15秒に設定して待機する。
手動実行ではないためブラウザ上にはエラーは表示されないものの
docker-compose側では下記のようなエラーが表示されているためタイムアウトが動作していることがわかる。

...
scheduled_worker_1  | [2020-10-23 03:05:30,343][PID:1][INFO][MainProcess] Received task: redash.tasks.execute_query[a266bd40-5abe-4515-ad69-f2f756142b7b]
scheduler_1         | [2020-10-23 03:05:30,346][PID:35][INFO][ForkPoolWorker-14] Task redash.tasks.refresh_queries[a9cd4248-7f95-434f-9b76-d4de42ab34b1] succeeded in 0.0338508000023s: None
scheduled_worker_1  | [2020-10-23 03:05:30,349][PID:22][INFO][ForkPoolWorker-1] task_name=redash.tasks.execute_query task_id=a266bd40-5abe-4515-ad69-f2f756142b7b task=execute_query state=load_ds ds_id=1
scheduled_worker_1  | [2020-10-23 03:05:30,354][PID:22][INFO][ForkPoolWorker-1] task_name=redash.tasks.execute_query task_id=a266bd40-5abe-4515-ad69-f2f756142b7b task=execute_query state=executing_query query_hash=115c0c43431f5ba5c1284589e62738a4 type=python ds_id=1  task_id=a266bd40-5abe-4515-ad69-f2f756142b7b queue=scheduled_queries query_id=1 username=Scheduled
scheduled_worker_1  | [2020-10-23 03:05:40,347][PID:1][WARNING][MainProcess] Soft time limit (10s) exceeded for redash.tasks.execute_query[a266bd40-5abe-4515-ad69-f2f756142b7b]
scheduled_worker_1  | [2020-10-23 03:05:40,348][PID:22][INFO][ForkPoolWorker-1] task_name=redash.tasks.execute_query task_id=a266bd40-5abe-4515-ad69-f2f756142b7b task=execute_query query_hash=115c0c43431f5ba5c1284589e62738a4 data_length=None error=[<class 'billiard.exceptions.SoftTimeLimitExceeded'> SoftTimeLimitExceeded()]
scheduled_worker_1  | [2020-10-23 03:05:40,357][PID:22][ERROR][ForkPoolWorker-1] Task redash.tasks.execute_query[a266bd40-5abe-4515-ad69-f2f756142b7b] raised unexpected: QueryExecutionError("<class 'billiard.exceptions.SoftTimeLimitExceeded'> SoftTimeLimitExceeded()",)
scheduled_worker_1  | Traceback (most recent call last):
scheduled_worker_1  |   File "/usr/local/lib/python2.7/site-packages/celery/app/trace.py", line 385, in trace_task
scheduled_worker_1  |     R = retval = fun(*args, **kwargs)
scheduled_worker_1  |   File "/app/redash/worker.py", line 84, in __call__
scheduled_worker_1  |     return TaskBase.__call__(self, *args, **kwargs)
scheduled_worker_1  |   File "/usr/local/lib/python2.7/site-packages/celery/app/trace.py", line 648, in __protected_call__
scheduled_worker_1  |     return self.run(*args, **kwargs)
scheduled_worker_1  |   File "/app/redash/tasks/queries.py", line 436, in execute_query
scheduled_worker_1  |     scheduled_query).run()
scheduled_worker_1  |   File "/app/redash/tasks/queries.py", line 382, in run
scheduled_worker_1  |     raise result
scheduled_worker_1  | QueryExecutionError: <class 'billiard.exceptions.SoftTimeLimitExceeded'> SoftTimeLimitExceeded()
...

タイムアウトが設定出来るのでうまく設定してやれば定期実行クエリがキューに詰まることは無くすことができそう。
その一方で、定期実行クエリが失敗した場合に結果は更新されず一見しただけでは失敗したかどうか分からない。
QueriesからLast Executed Atを参照する、ないしは結果内に最後に実行した時間を含めるようにしないと
古いデータと気づかずに利用してしまう可能性があるのでそこは注意が必要。

雑記

手動実行クエリについて調査していた時に__init__.pyを眺めていて
ドキュメント化されてないけど定期実行にもタイムアウトあるやんけと思い

みたいなことを投稿したのだけど
おそらく追記漏れだろう、ということで本家ドキュメントにもprを出した。
マージされるといいですね。おわり。

RedashのPythonデータソースで自作の再帰関数が動作しない

そもそもRedashのPythonデータソースで再帰関数を書くな。

Redashではデータソースとして各種DBやDWH以外にPythonで記述したスクリプトも利用することができる。
検証のために実行時間が数十秒かかるようなスクリプトを書いた際にハマったので、調べて分かったことをまとめる。

要約

  • Redash内で記述出来るPythonスクリプトはRestrictedPythonを利用して実行されている
  • RestrictedPythonを利用してる都合上様々な制限がある
  • 利用したい関数をglobalとすれば利用できる

環境

  • Redash v8.0.0 (dev)

経緯

Redashで時間がかかりすぎているクエリを特定した時にどう対応するかを確認するために
あえて、数十秒程度実行に時間がかかるような処理をクエリとして実行したかった。

一定時間かかる処理かつある程度負荷をパラメータで調整できるような処理をSQLでは
すぐには思いつかなかったのでPythonスクリプトでフィボナッチ数を計算するスクリプトを書いた。

以下のソースコードがまず動かない

print "start"
def fibonacci(n):
    if n <= 2:
        return 1
    else:
        return fibonacci(n - 2) + fibonacci(n - 1)
print fibonacci(5)
print "finish"

実行すると
Error running query: <type 'exceptions.NameError'> global name 'fibonacci' is not defined
とエラーが出力されてスクリプトが動作しない。

自分のPython力が不安になりpaiza.ioで試すと問題なく動作する。
次に関数定義が制限されてるのかと考えて下記の処理を書いた。

def hello():
  print "hello"

hello()

問題なく動作する。
次に自作関数から別の自作関数を定義するような処理を書いた。

def a():
  print "hello"

def b():
  a()

b()

Error running query: <type 'exceptions.NameError'> global name 'a' is not defined
で最初のコード例と同様に動作しない。

自作関数から自作関数をコールすると死ぬ?なぜ?
と思って調べたら以下のようなIssueが公式にあった

github.com

まさに同じような問題に直面している。
globalキーワードをつければ動くよとのことなので最初のコードでは
下記のようにfibonacciにglobalをつけて動作を確認することができた。

print "start"
def fibonacci(n):
    if n <= 2:
        return 1
    else:
        return fibonacci(n - 2) + fibonacci(n - 1)
global fibonacci
print fibonacci(5)
print "finish"

このコードだと動作する。
めでたしめでたし。

以下蛇足

なぜメソッド名が見つからない(制限されているのか)?

先程のIssueコメントのリンク内では

https://github.com/getredash/redash/blob/master/redash/query_runner/python.py#L263

でExceptionを吐くと言ってるが当時のmasterを指しているので正確ではなく
コメント時期から推測するにRedash v2.0.1の

https://github.com/getredash/redash/blob/22aa64571a2e827672d0a68f7daebd3516e3f6f8/redash/query_runner/python.py#L263

...
            exec(code) in restricted_globals, self._script_locals
...

この部分でExceptionが発生している。

前段のcompile_restricted で生成されているバイトコードを実行しようとしているので RestrictedPython.compile_restrictedを調べると

pypi.org

RestrictedPythonというものがあることがわかる。

RestrictedPythonとは

ユーザーから受け取ったPythonスクリプトを安全に実行するためのライブラリ
Pythonのビルトイン関数のcompileを置き換え、利用可能なモジュール、メソッドを制限することで
バイトコードの段階で怪しい処理をチェックする、とのこと。

定義済みの利用可能なモジュール、メソッドのセットを使用したり、自前で追加削除が可能な模様。
RedashではPythonクエリランナーのこのあたりで処理していそう。

具体的にどこで制限しているの

わかりませんでした。

このあたりで入ってきたバイトコードを見つつ
not_allowedなどで使用できないキーワードを決めたりしているところまでは確認できるものの
明確に辞書から外す、入れるみたいな処理を確認できなかった。

メソッド定義を発見した時の処理で、色々とやっているように見えるけれど
そもそも例外が送出されるタイミングがバイトコードを実行するexec側なので
戻りのバイトコードが壊れてる(または意図的に壊している)のか判断つかなかった。

似たような言及を探してみると
以下の記事ではPythonスクリプトの先頭でimportしたライブラリが使えないので
メソッド内でimportしろという記述もある

www.ehfeng.com

RestrictedPythonのドキュメントを見てもどの部分がこの機能に相当するのかわからない…
Pythonの言語仕様自体に対して理解が浅いだけかもしれないしこれ以上はハマりそうなので撤退

ウオーなんでやと思って調査しはじめたら3時間くらい溶けた…
オチとしてはよくわかりませんでしたで締まらないけど
初見のソースコードをガッツリ掘っていくのもたまには楽しい。

Redashでユーザーにクエリの閲覧と実行のみを許可する

Redashから出力したデータを監査等の別プロセスで利用する場合や、利用部門のリテラシが低い場合など
全ユーザーがなんでも出来てしまうと問題があるものの、ある程度制限した状態でRedashを利用してほしい場合がある。

そこで、用意されたパラメータつきクエリの閲覧と実行のみができるユーザーを作成したい。

tl;dr

  • Redashの権限管理は2種類ある
    • データソース毎の設定 (Full Access / View Only)
    • 実行できるアクションの設定
  • アクションへの設定は管理画面からは行えないのでCLIで行う必要がある
  • パラメータを都度取るようなクエリへの閲覧実行権限は後者でないと設定できない

環境

  • MacOS 10.14.6
  • docker desktop 2.1.0.5
  • Redash 8.0.0+b32245

検証にあたっては、id:kakku22さんのredash-hands-onリポジトリを利用した。
Dockerだけ入れていれば手元で試せるので、本家サイトの導入例よりとても手軽で最高。

github.com

解決したい課題

クエリ編集権限を与えずに、パラメータ付きクエリを閲覧実行できる権限をユーザーに設定したい。

Redash上から行える権限管理ではデータソースに対するFull AccessかView Onlyのどちらかしか設定できない。
単純にView Onlyになっている状態だと、パラメータ付きクエリにパラメータを渡して結果の絞り込みが行えない。
逆にFull Accessを与えてしまうとクエリの編集削除、その他諸々のことが行えてしまう。

そのため、新規クエリ発行・既存クエリ編集などをさせない状態で
既存のパラメータつきクエリを利用できる状態を用意したい。

権限モデル

Redashではグループに紐づく2種類の権限モデルが存在する。
https://redash.io/help/user-guide/users/permissions-groups

  • データソース毎の設定 (Full Access / View Only)
  • 実行できるアクションの設定

データソース毎の設定については右上のメニューGroups > [グループ名] > Data Sourcesから 以下のような画面で設定が行える。
Full Accessでは全機能が利用でき、View Onlyでは既に実行された結果のみが閲覧できる。

内部的にはdata_source_groupsというテーブル内に情報が格納されている。
adminグループへの設定レコードはdata_source_groupsに存在しない。

postgres=# select * from data_source_groups;
id | data_source_id | group_id | view_only
----+----------------+----------+-----------
  1 |              1 |        2 | f

後者のアクションへの権限はRedash上からは確認できないため
付属されているCLIツールのmanage.pyを利用するか、直接groupsテーブルを更新する必要がある。

postgres=# select id, org_id, type, name, permissions from groups;
 id | org_id |  type   |   name   |                                                                              permissions
----+--------+---------+----------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  1 |      1 | builtin | admin    | {admin,super_admin}
  2 |      1 | builtin | default  | {create_dashboard,create_query,edit_dashboard,edit_query,view_query,view_source,execute_query,list_users,schedule_query,list_dashboards,list_alerts,list_data_sources}

permissionsカラムで列挙されている内容が、そのグループで実行できるアクションとなっている。
指定できるpermissionは10種類近くあるようだが、対応するドキュメントが公式にはないので試行錯誤する必要がある。

今回設定をしたかったクエリの閲覧実行のみを許可したい場合は
データソースへをFull Access, 許可するアクションを{view_query,execute_query}と設定することで実現出来た。

それぞれ、ViewOnly, ExecuteOnlyを用意して設定した後はこちら。

postgres=# select * from data_source_groups;
 id | data_source_id | group_id | view_only
----+----------------+----------+-----------
  1 |              1 |        2 | f
  2 |              1 |        3 | t
  3 |              1 |        4 | f
postgres=# select id, org_id, type, name, permissions from groups;
 id | org_id |  type   |   name   |                                                                              permissions
----+--------+---------+----------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  1 |      1 | builtin | admin    | {admin,super_admin}
  2 |      1 | builtin | default  | {create_dashboard,create_query,edit_dashboard,edit_query,view_query,view_source,execute_query,list_users,schedule_query,list_dashboards,list_alerts,list_data_sources}
  3 |      1 | regular | ViewOnly | {create_dashboard,create_query,edit_dashboard,edit_query,view_query,view_source,execute_query,list_users,schedule_query,list_dashboards,list_alerts,list_data_sources}
  4 |      1 | regular | ExecuteOnly | {view_query,execute_query}

後はユーザーをそれぞれのグループに所属させればグループに指定された権限を持つようになる。

なお、クエリはデータソースに紐付いているため
事前に利用したいクエリが存在するデータソースをグループに追加しておく必要がある。

設定毎のRedash上でのクエリの見え方

View Onlyのみ

固定クエリ

問題なく実行できる

クエリ自体の編集は保存時にエラー

パラメータ付きクエリ

表示するだけでエラー

ExecuteOnly (Full Access かつ {view_query, execute_query} )

固定クエリ

問題なく表示されているものの
許可されないアクションはリンク自体表示されていないため編集画面を出せない

パラメータ付きクエリ

問題なく表示される

パラメータを変更して再実行も可能

Integerの比較で結果が安定しない

というのでハマった、という話を聞いたので
なぜそうなるのかを深堀りして確認した。

Integerの比較で結果が安定しない、なぜか?

gist.github.com

出力結果

true
false

短い回答

Integerは参照型なので、== を利用した比較では値の比較ではなく
それらが同一のインスタンスであるかどうかを判定する。
そのため、値の比較にはInteger.equalsメソッドを利用する必要がある。

長い回答

正しい比較ではないのは言語仕様からも明らかなものの
何故こうなるのかを調べるために内部での仕様を確認していく。

前提

利用したJava環境、参照したソースのバージョンはOracleJDK 13.0.1

書くまでも無いけれどInteger同士の値の比較をしたい訳なので intで比較したときと同様の結果になってほしい。
先にint同士で比較した望ましい結果を確認する。

gist.github.com

出力結果

true
true

当然、Integerの場合でもこのように動いてほしい。

実際に行われていること

ではint同士の比較と、Integer同士の比較を行う小さなコードを書いて
それぞれのバイトコードを比較して差分を確認する。

int同士の比較

intの比較をして出力するだけのクラス gist.github.com

javap -v -classpath classes IntCompare.class として上記のクラスを出力した結果 gist.github.com

Integer同士の比較

Integerの比較をして出力するだけのクラス gist.github.com

javap -v -classpath classes IntegerCompare.class として上記のクラスを出力した結果 gist.github.com

差分

基本データ型のintへの代入と、参照型のIntegerへの代入部分だけを抜き出して比較する

int

    Code:
      stack=3, locals=3, args_size=1
         0: bipush        100
         2: istore_1

Integer

    Code:
      stack=3, locals=3, args_size=1
         0: bipush        100
         2: invokestatic  #7                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         5: astore_1

bipush, isotre, astoreなどのオペコードについてはここを見てもらうとして
Integerへの代入の方では元ソースに記述の無いInteger.valueOfが呼び出されているのが確認できる。

これはJava1.5から導入されたオートボクシングという機能で
基本データ型の値を、対応する参照型の変数に変換することを容易にするというもの。
Java1.4まではプログラマが明示的にnew Integer(int型の値)などと記述する必要があった。

Integer.valueOfの実装

Integerの場合は代入時にInteger.valueOfが呼ばれて値を設定していることがわかったので
次にInteger.valueOfは内部でどういったことをしているのか確認する。

Integer.javaからInteger.valueOfの部分だけを抜粋する。

    /**
     * Returns an {@code Integer} instance representing the specified
     * {@code int} value.  If a new {@code Integer} instance is not
     * required, this method should generally be used in preference to
     * the constructor {@link #Integer(int)}, as this method is likely
     * to yield significantly better space and time performance by
     * caching frequently requested values.
     *
     * This method will always cache values in the range -128 to 127,
     * inclusive, and may cache other values outside of this range.
     *
     * @param  i an {@code int} value.
     * @return an {@code Integer} instance representing {@code i}.
     * @since  1.5
     */
    @HotSpotIntrinsicCandidate
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

引数として与えられたintの値がIntegerCacheのlowからhighの間であれば
Integer.IntegerCache.cacheから返し、そうでなければ新しいインスタンスを生成している。

Integer.IntegerCache

次にInteger.valueOf内で利用されているIntegerCacheについて確認する。
同様にInteger.javaからInteger.IntegerCacheの部分だけを抜粋する。

    /**
     * Cache to support the object identity semantics of autoboxing for values between
     * -128 and 127 (inclusive) as required by JLS.
     *
     * The cache is initialized on first usage.  The size of the cache
     * may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
     * During VM initialization, java.lang.Integer.IntegerCache.high property
     * may be set and saved in the private system properties in the
     * jdk.internal.misc.VM class.
     *
     * WARNING: The cache is archived with CDS and reloaded from the shared
     * archive at runtime. The archived cache (Integer[]) and Integer objects
     * reside in the closed archive heap regions. Care should be taken when
     * changing the implementation and the cache array should not be assigned
     * with new Integer object(s) after initialization.
     */

    private static class IntegerCache {
        static final int low = -128;
        static final int high;
        static final Integer[] cache;
        static Integer[] archivedCache;

        static {
            // high value may be configured by property
            int h = 127;
            String integerCacheHighPropValue =
                VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                try {
                    h = Math.max(parseInt(integerCacheHighPropValue), 127);
                    // Maximum array size is Integer.MAX_VALUE
                    h = Math.min(h, Integer.MAX_VALUE - (-low) -1);
                } catch( NumberFormatException nfe) {
                    // If the property cannot be parsed into an int, ignore it.
                }
            }
            high = h;

            // Load IntegerCache.archivedCache from archive, if possible
            VM.initializeFromArchive(IntegerCache.class);
            int size = (high - low) + 1;

            // Use the archived cache if it exists and is large enough
            if (archivedCache == null || size > archivedCache.length) {
                Integer[] c = new Integer[size];
                int j = low;
                for(int i = 0; i < c.length; i++) {
                    c[i] = new Integer(j++);
                }
                archivedCache = c;
            }
            cache = archivedCache;
            // range [-128, 127] must be interned (JLS7 5.1.7)
            assert IntegerCache.high >= 127;
        }

        private IntegerCache() {}
    }

メソッドなどの実装はなく、全てstaticイニシャライザで処理が完結している。
CDSアーカイブされたデータが無ければある範囲の値を生成してキャッシュしていることがわかる。
(java.lang.Integer.IntegerCache.high の指定がなければ-128~127の範囲)

Integer.valueOfの実装と合わせて読むと、引数が特定の範囲に入っているケースであれば
毎回新しいインスタンスを生成するのではなく、キャッシュ済みのIntegerを返却している。

そのため、記事の頭にあるコードの場合では
値が100の場合はキャッシュ内から同一のインスタンスが返却されるためtrueとなり
値が200の場合は毎回新しいインスタンスが生成されて返却されるためfalseとなる。

最後に、境界値を含む検証コードを書いて挙動を確認する。

gist.github.com

出力結果

true
false
true
false

確かにコードで確認した通りの値域で想定された動作をしていることがわかる。

ちなみに、IntegerだけでなくByte, Character, Longでも同様のキャッシュ機構は存在しており
ByteとLong(-128~127), Character(0~127)の範囲でキャッシュしているが
Integerのようにキャッシュする範囲を変更することはできない。

キャッシュ値域内でも比較を失敗させる方法

オートボクシング時のvalueOfを回避すれば、キャッシュが利用されないため
(当然だが)Integer間の==での比較は正しく(?)失敗する。
例えば下記のように明示的に同じ値を持つ異なるインスタンスを作成すればよい。

    public static void main(String[] args) {
        Integer a = new Integer(100);
        Integer b = new Integer(100);

        System.out.println(a == b);
    }

出力結果

false

Booleanなどは2値しか無いので当然内部でキャッシュを持っているが
コンストラクタを利用すればtrueかつBoolean.TRUEと異なるインスタンスを作ることが出来る。

結論

Integer.valueOfは一定範囲の値をキャッシュしているため
オートボクシング(Integer.valueOf)経由でIntegerとなった値がその範囲に入った場合
==での比較にtrueを返してしまうケースがある。

普段の実装ではハマることも無いのでまあそうかという感じではあるものの
たまに何気なく触っているクラスの内部実装を見ると面白い。

有名OSSの中にポケモンがいた話

リポジトリ名をポケモン名に合わせて頭字語にしたとかそういうことではなく
ソースコード内のコメントにアスキーアートポケモン(ヒトカゲ)がいる。

どこ

Java界隈では有名な Google GuavaというOSSライブラリの
com.google.common.base.CharMatcher.java内のコメントに存在する。

ちなみに、Google Guavaは2019年7月2日時点で
Star数は32,000以上、Fork数は7,000超えの超有名OSS

github.com

開いた感じだとこう。

f:id:uskey:20190702010254p:plain:w300

かわいいですね。
最初のジムリーダーがいわタイプなので選ぶことはなかったけど、初代っぽいかわいさにあふれています。

仕事中に同僚のコードでCharMatcherを使っている箇所があったので
何するクラスだっけと思って調べたのが見つけたきっかけ。ほっこりした。

なんで

多分Charmander(ヒトカゲの英名)とCharMatcherのスペルが似てるから…?わからん…
任天堂に許可取ってる?みたいなコメントがついてるけど無事(?)マージされたぽい。

2016年の以下のコミットで入ったようだけどマージされた経緯が全くわからない…

github.com

おまけ

どのコミットで入ったんやと思って調べるときに下記の拡張を使いました。
すげー便利なので興味ある人はつかってみてね!

github.com