Redash のクエリ定期実行はどのように実現されているのか
内容は本記事を書いた2018/06/03時点の理解なので、間違っていたら直す。
対象のバージョン
Redash 4.0.1 c86423a で確認した。
ざっくり言うと
30秒ごとにクエリの最終取得日時と定期実行の設定を比較し、再実行が必要なものを scheuled_queries
キューで実行する。
クエリ定期実行の流れ
- 30秒に1度、redash.tasks.queries.refresh_queries が実行される
- celery beat を使って定期実行されている
- デフォルトでは定期実行の最小値が毎時1分になっているのは、
celery beat
が30秒に1度動くためかもしれない- 「30秒」というところは今のところベタ書きされている
- 各クエリの
retrieved_at
や、そのクエリのスケジュール実行設定をみて、再実行が必要かどうかを判断するFEATURE_DISABLE_REFRESH_QUERIES
が有効になっているとスケジュール実行はすべて無視される
- クエリパラメータを使用している場合は、
queries
テーブルに保存されているデフォルトのパラメータをクエリにバインドする。クエリパラメータを使用していない場合クエリ文字列を取得する - キューにタスクを追加する
- Redash では画面からのクエリ実行と定期実行のキューが別れていて、定期実行は
scheuled_queries
キューを使用する
- Redash では画面からのクエリ実行と定期実行のキューが別れていて、定期実行は
- 全クエリに対して上記の処理をする
- 再実行対象数と、再実行されたクエリIDのリスト、実行日時を Redis の
redash:status
にハッシュとして書き込む- Redash のステータスページでみられる項目の一部はこの情報を使っているはず(そこまでは追っていない)
まとめ
Redis を FLUSHALL
しても、定期実行が動き続ける理由が知れてよかった。
Celery を試してみる
Redash を介してお世話になることはあれど、単体で使ったことがなかったので改めて Celery を試してみる。
Homepage | Celery: Distributed Task Queue
Python のバージョンは 3.6.5。Celery のバージョンは 4.1.1 を使用した。
Celery とは
Celery: Distributed Task Queue
分散タスクキュー。とのこと。
Celery で使えるメッセージブローカ
Brokers — Celery 4.1.0 documentation
RabbitMQ, Redis, Amazon SQS あたりが使えるとのこと。今回は Redash の理解を深めることも目的のひとつなので、Redis を使うことにする。
結果を取得するための Result Backend
First Steps with Celery — Celery 4.1.0 documentation
実行されたタスクの結果を利用したい場合、結果の保存には Result Backend という仕組みをつかうらしい。
保存先は SQLAlchemy, Memcached, Redis などが使用できるようだが、今回は Result Backend も Redash にあわせて Redis を使う。
試してみる
worker 上で動作するタスクの実装
非同期で動くことを確認するため、与えられたメッセージ文字列を0-2秒待ったあとに、そのまま結果として返す簡単なタスクを用意した。
from celery import Celery from time import sleep from random import randint app = Celery('tasks', backend='redis://localhost', broker='redis://localhost') @app.task def hello(m): sleep(randint(0, 2)) return m
非同期タスクを呼び出すアプリケーションの実装
FizzBuzz ループの中で 3 Fizz
のような文字列を先程のタスクで実行する。
すべて非同期実行をしたあとに、処理結果を確認、表示する。
from collections import deque import tasks results = deque([]) for i in range(1, 42): if i % 15 == 0: results.append(tasks.hello.delay('{} FizzBuzz'.format(i))) elif i % 3 == 0: results.append(tasks.hello.delay('{} Fizz'.format(i))) elif i % 5 == 0: results.append(tasks.hello.delay('{} Buzz'.format(i))) else: results.append(tasks.hello.delay('{}'.format(i))) print('すべてのタスクがキューに入りました') while len(results) > 0: result = results.popleft() if result.ready(): print(result.get()) continue results.append(result)
実行してみる
まずは worker を起動する。
$ celery --app=tasks worker --loglevel=info -c 2
--app
で非同期実行されるタスクが書かれたスクリプト名を渡し、 --loglevel
は文字通りログレベル、 -c
で並列実行のための子プロセス数を指定する。
次に、アプリケーションを実行する。
これはただの Python スクリプトなので、 python
コマンドで実行するだけ。
$ python main.py すべてのタスクがキューに入りました 1 2 4 3 Fizz 5 Buzz ...省略... 39 Fizz 40 Buzz 41
実行すると、「すべてのタスクがキューに入りました」というメッセージのあとに、1から41までの数字と、条件にあった数値は「Fizz」などの文字列を付加した結果が表示される。
上の例では、 2
の次の結果が 4
になっているが、これはタスク側でランダムに待ちをいれているためなので問題なし。
一方、アプリケーション実行時の worker 側の出力はこんな感じ。
[2018-05-28 20:08:15,640: INFO/MainProcess] Received task: tasks.hello[cbf77878-c2b4-48ec-8214-491155479188] [2018-05-28 20:08:15,643: INFO/MainProcess] Received task: tasks.hello[f1808ca7-f663-4c9e-8343-be5581b464da] [2018-05-28 20:08:15,647: INFO/MainProcess] Received task: tasks.hello[74ade9ad-8136-4ed9-8ee1-029cb9a003a9] [2018-05-28 20:08:15,652: INFO/MainProcess] Received task: tasks.hello[02925313-3346-41a8-bab8-9b4506151fc4] [2018-05-28 20:08:15,657: INFO/MainProcess] Received task: tasks.hello[e0558aef-3206-4feb-a09e-97bc48e9496d] [2018-05-28 20:08:15,661: INFO/MainProcess] Received task: tasks.hello[3dae0719-daa8-4c42-b703-1a2bf7293bae] [2018-05-28 20:08:15,667: INFO/MainProcess] Received task: tasks.hello[94a9a252-2794-413f-8aac-fd781a893cab] [2018-05-28 20:08:15,674: INFO/MainProcess] Received task: tasks.hello[84d9a82a-571a-477b-8911-4010083cc0b6] ...省略... [2018-05-28 20:08:35,937: INFO/ForkPoolWorker-2] Task tasks.hello[7c86fa11-a1c4-4261-900a-bcd6f261d76d] succeeded in 0.0011464759991213214s: '36 Fizz' [2018-05-28 20:08:36,940: INFO/ForkPoolWorker-2] Task tasks.hello[8f6658a9-2fcc-47a7-98e8-cae9a8096546] succeeded in 1.0012103780027246s: '37' [2018-05-28 20:08:37,813: INFO/ForkPoolWorker-1] Task tasks.hello[af3513f5-c6af-4e64-b324-7c4b1f8634bc] succeeded in 2.0011870139969687s: '35 Buzz' [2018-05-28 20:08:37,945: INFO/ForkPoolWorker-2] Task tasks.hello[38ecd7d3-2897-43ba-8e0c-1f36f24d7acb] succeeded in 1.0030830889991194s: '38' [2018-05-28 20:08:38,836: INFO/ForkPoolWorker-1] Task tasks.hello[f58921ba-5322-419a-b3d9-5d6f6d27e2ba] succeeded in 1.0212826329989184s: '39 Fizz' [2018-05-28 20:08:38,947: INFO/ForkPoolWorker-2] Task tasks.hello[1fff8e54-ded8-4a85-86f5-9f68fd9f2aa5] succeeded in 1.0010614519997034s: '40 Buzz' [2018-05-28 20:08:40,861: INFO/ForkPoolWorker-1] Task tasks.hello[2ac6cfeb-e204-427b-9ea6-7ddbd6fcfc6f] succeeded in 2.0224582139999256s: '41'
キューにタスクが積まれて、その後徐々に処理されていく様子がわからなくもない。
タスクには一意の ID が振られていて、ぱっと見で UUID っぽく見える。
worker を2つにしてみる
コードは変えず、 Celery の worker プロセスをもう一つ起動してみる。
$ celery --app=tasks worker --loglevel=info -c 2
この状態でアプリケーションを実行すると。
$ python main.py すべてのタスクがキューに入りました 3 Fizz 1 2 4 5 Buzz ...省略... 31 36 Fizz 38 34 40 Buzz
worker が増えたため、先程の実行結果よりさらに処理順序にばらつきが出ている。
worker 側の出力は割愛するが、 どちらの worker でも実行ログが出ているので、おそらく均一に処理されているものと思われる。
まとめ
タスクキューってだいたいこんな感じだよね。という使い心地だった。
Redash のコードを追う際の基礎知識として、触ってみてよかったと思う。
今度は Redash のクエリワーカを複数にしても問題なく動作するのか?という検証をしてみるつもり。
余談: Celery のタスクはどのような形で メッセージブローカ(Redis)に保存されているのか?
気になったので redis-cli
で見てみる。
$ redis-cli 127.0.0.1:6379> keys * ...省略... 231) "celery-task-meta-6dae9f06-bfb5-497a-8926-2b03dfb8a1a3" 232) "celery" 233) "celery-task-meta-68ab5751-0fa7-45d6-9c97-9aee871b410b" ...省略...
celery
というキーがあったので型を確認したら list
型だった。
127.0.0.1:6379> type celery list
1件取り出してみる。
127.0.0.1:6379> LRANGE celery 0 0 1) "{\"body\": \"W1siNDEiXSwge30sIHsiY2FsbGJhY2tzIjogbnVsbCwgImVycmJhY2tzIjogbnVsbCwgImNoYWluIjogbnVsbCwgImNob3JkIjogbnVsbH1d\", \"content-encoding\": \"utf-8\", \"content-type\": \"application/json\", \"headers\": {\"lang\": \"py\", \"task\": \"tasks.hello\", \"id\": \"b69ad207-308f-4c73-988a-8321a640dbdc\", \"eta\": null, \"expires\": null, \"group\": null, \"retries\": 0, \"timelimit\": [null, null], \"root_id\": \"b69ad207-308f-4c73-988a-8321a640dbdc\", \"parent_id\": null, \"argsrepr\": \"('41',)\", \"kwargsrepr\": \"{}\", \"origin\": \"gen14561@ariarijp.local\"}, \"properties\": {\"correlation_id\": \"b69ad207-308f-4c73-988a-8321a640dbdc\", \"reply_to\": \"e3c01706-26cb-3330-98aa-b5551bd6e56b\", \"delivery_mode\": 2, \"delivery_info\": {\"exchange\": \"\", \"routing_key\": \"celery\"}, \"priority\": 0, \"body_encoding\": \"base64\", \"delivery_tag\": \"0c5d61f8-ae4a-492e-904f-cbd4b551c3ca\"}}"
JSON。ちょっと見にくいので整形するとこんな感じ。
{ "body": "W1siNDEiXSwge30sIHsiY2FsbGJhY2tzIjogbnVsbCwgImVycmJhY2tzIjogbnVsbCwgImNoYWluIjogbnVsbCwgImNob3JkIjogbnVsbH1d", "content-encoding": "utf-8", "content-type": "application/json", "headers": { "lang": "py", "task": "tasks.hello", "id": "b69ad207-308f-4c73-988a-8321a640dbdc", "eta": null, "expires": null, "group": null, "retries": 0, "timelimit": [ null, null ], "root_id": "b69ad207-308f-4c73-988a-8321a640dbdc", "parent_id": null, "argsrepr": "('41',)", "kwargsrepr": "{}", "origin": "gen14561@ariarijp.local" }, "properties": { "correlation_id": "b69ad207-308f-4c73-988a-8321a640dbdc", "reply_to": "e3c01706-26cb-3330-98aa-b5551bd6e56b", "delivery_mode": 2, "delivery_info": { "exchange": "", "routing_key": "celery" }, "priority": 0, "body_encoding": "base64", "delivery_tag": "0c5d61f8-ae4a-492e-904f-cbd4b551c3ca" } }
body
の中身は Base64 エンコードしてるように見えるのでデコードしてみた。
[["41"], {}, {"callbacks": null, "errbacks": null, "chain": null, "chord": null}]
celery-task-meta- *
みたいなのも気になるので見てみる。
127.0.0.1:6379> type celery-task-meta-e6f5f414-1d8f-42c2-b614-75f9615ec61f string
文字列型。JSON かな。
127.0.0.1:6379> get celery-task-meta-e6f5f414-1d8f-42c2-b614-75f9615ec61f "{\"status\": \"SUCCESS\", \"result\": \"41\", \"traceback\": null, \"children\": [], \"task_id\": \"e6f5f414-1d8f-42c2-b614-75f9615ec61f\"}"
JSON だった。Result Backend を使用すると、きっと celery-task-meta-*
に結果を書き出すのだろう。
例外を起こしたらどうなるのか?
気になったので、例外が起きるように書き換え、その結果を見てみた。
127.0.0.1:6379> get celery-task-meta-d5024461-52b3-429b-ab71-57c8e76522b3 "{\"status\": \"FAILURE\", \"result\": {\"exc_type\": \"Exception\", \"exc_message\": \"\"}, \"traceback\": \"Traceback (most recent call last):\\n File \\\"/Users/ariarijp/.local/share/virtualenvs/celerytutorial-nzo1Q02k/lib/python3.6/site-packages/celery/app/trace.py\\\", line 375, in trace_task\\n R = retval = fun(*args, **kwargs)\\n File \\\"/Users/ariarijp/.local/share/virtualenvs/celerytutorial-nzo1Q02k/lib/python3.6/site-packages/celery/app/trace.py\\\", line 632, in __protected_call__\\n return self.run(*args, **kwargs)\\n File \\\"/Users/ariarijp/workspace/python/celerytutorial/tasks.py\\\", line 12, in hello\\n raise Exception()\\nException\\n\", \"children\": [], \"task_id\": \"d5024461-52b3-429b-ab71-57c8e76522b3\"}"
ちょっと長いので整形。
{ "status": "FAILURE", "result": { "exc_type": "Exception", "exc_message": "" }, "traceback": "Traceback (most recent call last):\\n File \\"/Users/ariarijp/.local/share/virtualenvs/celerytutorial-nzo1Q02k/lib/python3.6/site-packages/celery/app/trace.py\\", line 375, in trace_task\\n R = retval = fun(*args, **kwargs)\\n File \\"/Users/ariarijp/.local/share/virtualenvs/celerytutorial-nzo1Q02k/lib/python3.6/site-packages/celery/app/trace.py\\", line 632, in __protected_call__\\n return self.run(*args, **kwargs)\\n File \\"/Users/ariarijp/workspace/python/celerytutorial/tasks.py\\", line 12, in hello\\n raise Exception()\\nException\\n", "children": [], "task_id": "d5024461-52b3-429b-ab71-57c8e76522b3" }
ステータスが FAILURE
になり、結果に例外の種類やトレースバックが含まれるようになった。
この値を使って結果を受け取るときに例外を発生させている様子。
ここのコードを追っていったら、Python で Promise を実現するための vine というモジュールがあることを知った。Celery プロジェクトの一部っぽい。
Redash のリリースビルドの場所を探す
ここ見ればわかる。
簡単。 jq
はお好みでどうぞ。
$ curl https://version.redash.io/api/releases | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 3424 100 3424 0 0 4626 0 --:--:-- --:--:-- --:--:-- 4620 [ { "id": 28, "version": "4.0.1", "channel": "stable", "download_url": "https://s3.amazonaws.com/redash-releases/redash.4.0.1.b4038.tar.gz", "backward_compatible": false, "released_at": "2018-05-02T00:00:00.000Z", "description": "* Before doing an upgrade, please make sure you have a backup.\n* If you have any issues, please refer to the troubleshooting section in the upgrade guide:\n https://redash.io/help/open-source/admin-guide/how-to-upgrade\n* If the upgrade guide doesn't help, you can ask for help on the forum (https://discuss.redash.io). \n\nFull CHANGELOG for this release: https://github.com/getredash/redash/blob/master/CHANGELOG.md", "docker_image": "redash/redash:4.0.1.b4038" }, { "id": 27, "version": "4.0.0", "channel": "stable", "download_url": "https://s3.amazonaws.com/redash-releases/redash.4.0.0.b3948.tar.gz", "backward_compatible": false, "released_at": "2018-04-16T00:00:00.000Z", "description": "* Before doing an upgrade, please make sure you have a backup.\n* If you have any issues, please refer to the troubleshooting section in the upgrade guide:\n https://redash.io/help/open-source/admin-guide/how-to-upgrade\n* If the upgrade guide doesn't help, you can ask for help on the forum (https://discuss.redash.io). \n\nFull CHANGELOG for this release: https://github.com/getredash/redash/blob/master/CHANGELOG.md", "docker_image": "redash/redash:4.0.0.b3948" }, { "id": 23, "version": "3.0.0", "channel": "stable", "download_url": "https://s3.amazonaws.com/redash-releases/redash.3.0.0.b3134.tar.gz", "backward_compatible": true, "released_at": "2017-11-13T00:00:00.000Z", "description": "* Before doing an upgrade, please make sure you have a backup.\n* If you have any issues, please refer to the troubleshooting section in the upgrade guide:\n https://redash.io/help-onpremise/maintenance/how-to-upgrade-redash.html\n* If the upgrade guide doesn't help, you can ask for help on the forum (https://discuss.redash.io). \n\nFull CHANGELOG for this release: https://github.com/getredash/redash/blob/master/CHANGELOG.md", "docker_image": "redash/redash:3.0.0.b3134" }, { "id": 22, "version": "2.0.1", "channel": "stable", "download_url": "https://s3.amazonaws.com/redash-releases/redash.2.0.1.b3080.tar.gz", "backward_compatible": true, "released_at": "2017-10-22T00:00:00.000Z", "description": "* Before doing an upgrade, please make sure you have a backup.\n* If you have any issues, please refer to the troubleshooting section in the upgrade guide:\n https://redash.io/help-onpremise/maintenance/how-to-upgrade-redash.html\n* If the upgrade guide doesn't help, you can ask for help on the forum (https://discuss.redash.io). \n\nFull CHANGELOG for this release:\nhttps://github.com/getredash/redash/blob/master/CHANGELOG.md#v201---2017-10-22", "docker_image": null }, { "id": 21, "version": "2.0.0", "channel": "stable", "download_url": "https://s3.amazonaws.com/redash-releases/redash.2.0.0.b2990.tar.gz", "backward_compatible": true, "released_at": "2017-08-08T00:00:00.000Z", "description": "* Before doing an upgrade, please make sure you have a backup.\n* If you have any issues, please refer to the troubleshooting section in the upgrade guide:\n https://redash.io/help-onpremise/maintenance/how-to-upgrade-redash.html\n* If the upgrade guide doesn't help, you can ask for help on the forum (https://discuss.redash.io). \n\nFull CHANGELOG for this release: https://github.com/getredash/redash/blob/master/CHANGELOG.md#v200---2017-08-08", "docker_image": null } ]
これがどういう時に欲しくなるかというと、VM で動いてる Ubuntu に特定のバージョンをインストールする時にバージョンとビルド番号が欲しくなる。
2.0.1 から 4.0.1 へのアップグレードを考えているので、検証を進めていこうと思う。
mozilla/redash_client を使って Redash のクエリを実行する
業務では redash-dynamic-query を使う機会が多いが、Mozilla が公開している mozilla/redash_client
も試してみることにした。
少し調べたところ、Mozilla で使われている STMO という仕組みが Redash で構築されていて、その STMO のクライアントとして開発されているっぽい。
STMO は誰でもアクセスできるものというわけではなさそうだけど、 Mozilla は内部で使っているコードもすべてオープンにするようなポリシーで運営されているのかもしれない。
使ってみる
README ではクエリを検索するサンプルが紹介されているので、それを少し改変して redash-dynamic-query
でよくやるパラメータを使ったクエリの実行をやってみる。
先に断っておくと、このコードは以下の PR に含まれる変更に依存しているので、私の手元でしか動かないことはご了承いただきたい。
from pprint import pprint import pystache from redash_client.client import RedashClient api_key = os.environ["REDASH_API_KEY"] client = RedashClient(api_key) client.BASE_URL = 'https://localhost:5000/' client.API_BASE_URL = client.BASE_URL + 'api/' query = client.get_query(1) sql = pystache.render(query['query'], { 'since': '2018-04-01', 'until': '2018-04-30', }) result = client.get_query_results(sql, query['data_source_id']) pprint(result, width=160)
上記のようなコードで、 redash-dynamic-query
と同じように、クエリにパラメータをバインドして実行することができる。
モジュール内で BASE_URL
が STMO の URL でべた書きされていたりするので、内部向けコードっぽさを強く感じるが、 Mozilla の外でも使えないことはなさそう。
redash-dynamic-query との違い
パラメータつきクエリの実行に特化している実装になっている redash-dynamic-query
に対し、 mozilla/redash_client
はすべてのAPIを網羅しているわけではないが、所謂 API クライアントとして作られている。
すべてを試したわけではないが、現時点の master
ブランチでは以下のような機能を持っている。
これらの機能を使って、Mozilla では Redash as a Code みたいな感じのことをやっているのかもしれない。
ただし、先程紹介した PR のように、ID を指定したクエリの取得など、よく使いそうな機能がまだ実装されていなかったりするので、 PR を送ったりするなどして開発に参加したり、Fork して必要な機能を足すようなことが必要になるかもしれない。
また、 .travis.yml
を見る限りでは Python 2.7 での使用を想定されているためか、 Python 3 では上記で紹介したコードを動作させることはできるものの、ユニットテストは失敗する。 個人的には Python 2 を使う機会が減っているため、このあたりは少し気になる。
Redash Meetup #0.2 - SQL 未経験者向けハンズオンを開催した
主催しているRedash Meetupの企画として SQL 未経験者向けハンズオンを開催しました。
ブログ書くのがずいぶん遅くなってしまったけど、まぁいろいろあった。
進行について
SQL 未経験者向けということで、一度でも SQL を書いたことがある人にとっては物足りなくなるレベルで内容を絞ることにした。
具体的にはイベントページの説明にも記載しているが、 WHERE
での条件指定を中心に、後半少しだけ GROUP BY
に触れるよう内容でハンズオンを構成した。
構成について気を使ったこと
コピペでほとんどの SQL を実行できるようににした
ハンズオンの大半は「ほぼコピペ」で進められるような構成にして、「何をやっても動かなくて詰まる」という状況を防ぐというのを意識した。
コピペで進めることにした理由としては、事前に社内のインターン向けにリハーサルをした際、「タイピング速度に依存して個々の進行度合いの差が大きく開く」というのが見受けられた。ということが大きい。
今回のハンズオンは「じっくり基礎を学ぶ」という特性のものではなく、「SQL を実行してみて、データベースからデータを取り出す感覚を掴んでもらう」ということを重視したため、このような形をとったが、イベントを終えてみると、参加者の方にとっては少し物足りないところもあったように思える。
このあたりは、ハンズオンイベントの難しさだなと思う。
コピペで実行する SQL の中にエラーを仕込んだ
サポート講師としてイベントをフォローというかドライブしてくれた id:kakku22 が表参道.rbで過去に発表した以下のスライドを思い出して、「SQL の中に潜むエラーを見つける」というのを、コピペ資料の中にこっそりと埋め込んだ。
これが好評だったか?というと、よくわからないというのが正直なところではあるけど、「SQL を書いていれば必ずエラーと向き合うことになる」ということを伝えることはできたと思う。
エラーを見てすぐに拒否反応するのではなく「エラーが出たってことは、どこか間違ってるんだろうな」と思ってもらえるようになっていたらうれしい。
後半は応用編として、前半のハンズオンとは全く違ったデータベースを使った演習を入れた
前半の内容は前述の通り「ほぼコピペ」で進める内容となっていて、具体的には MySQL のサンプルデータとして有名な world
データベースを使用していた。
一方、後半の内容は sakila
データベース(ビデオレンタル店を模したデータ)を使用し、以下のような課題に取り組む形をとった。
- (Redashで)テーブルの一覧、カラムの一覧を見てみる
- 顧客数をカウントする
- 店舗別の顧客数をカウントする
- 再生時間が長い映画トップ20を取得する
- タイトルに GOLD を含む映画を取得する
- (チャレンジ演習) 最も出演回数の多い俳優または女優の名前は?
前半にコピペをしながら取り組んだ SQL を参考にすれば、正解にたどり着けるような課題にしつつ、未知のデータベースにどんなテーブルやカラムがあるか、何件のデータが保持されているかなど、少し実務でも活用できそうなお題を選んだつもり。
改善点
SQL を「書く」体験をもう少し多くしてもよかった
前述の「コピペ進行」の話のとおりなので割愛
資料を公開できるように準備したかった
イベント企画時点では、イベント後に資料を公開することを計画していたが、イベント内で口頭補足する前提の資料にしてしまったことや、Redash のハンズオン環境環境がないと実感に欠ける資料になってしまったため、公開は見送ることにした。
次回開催をする場合は、この点を解決したい。
まとめ
- ハンズオンは準備と内容のレベル調整が大変
- みなさんの反応を見ながら進行できるのは面白い
- 次回開催は未定。定員を大きく超える応募をいただいたので、また開催してみたいとは思っている
次回開催、どうしようかなぁ。と思いを巡らせてみてはいるものの、まずは直近のイベントの準備・開催を終えてから考えることにしよう。
Redash Meetup についての宣伝
Redash Meetup #2 では コネヒト株式会社 の shnagai さんに登壇をお願いしました。Redash に興味があるかたは、こちらのイベントへのご参加もご検討ください。