Commit e4fec520 authored by Sean McGivern's avatar Sean McGivern

Merge branch...

Merge branch '27790-periodically-save-last-activity-date-data-from-redis-to-the-database-hscan' into 'master'

[Improved] Periodically save last activity date data from Redis to the database

See merge request !1597
parents 0e519d55 fca21718
...@@ -972,10 +972,6 @@ class User < ActiveRecord::Base ...@@ -972,10 +972,6 @@ class User < ActiveRecord::Base
end end
end end
def record_activity
Gitlab::UserActivities::ActivitySet.record(self)
end
def access_level def access_level
if admin? if admin?
:admin :admin
......
...@@ -14,7 +14,7 @@ module Users ...@@ -14,7 +14,7 @@ module Users
private private
def record_activity def record_activity
@author.record_activity unless Gitlab::Geo.secondary? Gitlab::UserActivities.record(@author.id) unless Gitlab::Geo.secondary?
Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}") Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}")
end end
......
class ScheduleUpdateUserActivityWorker
include Sidekiq::Worker
include CronjobQueue
def perform(batch_size = 500)
return if Gitlab::Geo.secondary?
Gitlab::UserActivities.new.each_slice(batch_size) do |batch|
UpdateUserActivityWorker.perform_async(Hash[batch])
end
end
end
class UpdateUserActivityWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
def perform(pairs)
return if Gitlab::Geo.secondary?
pairs = cast_data(pairs)
ids = pairs.keys
conditions = 'WHEN id = ? THEN ? ' * ids.length
User.where(id: ids).
update_all([
"last_activity_on = CASE #{conditions} ELSE last_activity_on END",
*pairs.to_a.flatten
])
Gitlab::UserActivities.new.delete(*ids)
end
private
def cast_data(pairs)
pairs.each_with_object({}) do |(key, value), new_pairs|
new_pairs[key.to_i] = Time.at(value.to_i).to_s(:db)
end
end
end
---
title: Periodically persists users activity to users.last_activity_on
merge_request: 1597
author:
...@@ -432,6 +432,11 @@ Settings.cron_jobs['clear_shared_runners_minutes_worker'] ||= Settingslogic.new( ...@@ -432,6 +432,11 @@ Settings.cron_jobs['clear_shared_runners_minutes_worker'] ||= Settingslogic.new(
Settings.cron_jobs['clear_shared_runners_minutes_worker']['cron'] ||= '0 0 1 * *' Settings.cron_jobs['clear_shared_runners_minutes_worker']['cron'] ||= '0 0 1 * *'
Settings.cron_jobs['clear_shared_runners_minutes_worker']['job_class'] = 'ClearSharedRunnersMinutesWorker' Settings.cron_jobs['clear_shared_runners_minutes_worker']['job_class'] = 'ClearSharedRunnersMinutesWorker'
# Every day at 00:30
Settings.cron_jobs['schedule_update_user_activity_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['schedule_update_user_activity_worker']['cron'] ||= '30 0 * * *'
Settings.cron_jobs['schedule_update_user_activity_worker']['job_class'] = 'ScheduleUpdateUserActivityWorker'
# #
# GitLab Shell # GitLab Shell
# #
......
...@@ -63,3 +63,4 @@ ...@@ -63,3 +63,4 @@
- [elastic_indexer, 1] - [elastic_indexer, 1]
- [elastic_commit_indexer, 1] - [elastic_commit_indexer, 1]
- [export_csv, 1] - [export_csv, 1]
- [update_user_activity, 1]
class AddLastActivityOnToUsers < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :users, :last_activity_on, :date
end
end
class MigrateUserActivitiesToUsersLastActivityOn < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
USER_ACTIVITY_SET_KEY = 'user/activities'.freeze
ACTIVITIES_PER_PAGE = 100
TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED = Time.utc(2016, 12, 1)
def up
return if activities_count(TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED, Time.now).zero?
day = Time.at(activities(TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED, Time.now).first.second)
transaction do
while day <= Time.now.utc.tomorrow
persist_last_activity_on(day: day)
day = day.tomorrow
end
end
end
def down
# This ensures we don't lock all users for the duration of the migration.
update_column_in_batches(:users, :last_activity_on, nil) do |table, query|
query.where(table[:last_activity_on].not_eq(nil))
end
end
private
def persist_last_activity_on(day:, page: 1)
activities_count = activities_count(day.at_beginning_of_day, day.at_end_of_day)
return if activities_count.zero?
activities = activities(day.at_beginning_of_day, day.at_end_of_day, page: page)
update_sql =
Arel::UpdateManager.new(ActiveRecord::Base).
table(users_table).
set(users_table[:last_activity_on] => day.to_date).
where(users_table[:username].in(activities.map(&:first))).
to_sql
connection.exec_update(update_sql, self.class.name, [])
unless last_page?(page, activities_count)
persist_last_activity_on(day: day, page: page + 1)
end
end
def users_table
@users_table ||= Arel::Table.new(:users)
end
def activities(from, to, page: 1)
Gitlab::Redis.with do |redis|
redis.zrangebyscore(USER_ACTIVITY_SET_KEY, from.to_i, to.to_i,
with_scores: true,
limit: limit(page))
end
end
def activities_count(from, to)
Gitlab::Redis.with do |redis|
redis.zcount(USER_ACTIVITY_SET_KEY, from.to_i, to.to_i)
end
end
def limit(page)
[offset(page), ACTIVITIES_PER_PAGE]
end
def total_pages(count)
(count.to_f / ACTIVITIES_PER_PAGE).ceil
end
def last_page?(page, count)
page >= total_pages(count)
end
def offset(page)
(page - 1) * ACTIVITIES_PER_PAGE
end
end
...@@ -1483,6 +1483,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do ...@@ -1483,6 +1483,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.boolean "authorized_projects_populated" t.boolean "authorized_projects_populated"
t.boolean "auditor", default: false, null: false t.boolean "auditor", default: false, null: false
t.boolean "ghost" t.boolean "ghost"
t.date "last_activity_on"
t.boolean "notified_of_own_activity" t.boolean "notified_of_own_activity"
t.boolean "support_bot" t.boolean "support_bot"
end end
......
...@@ -74,6 +74,7 @@ GET /users ...@@ -74,6 +74,7 @@ GET /users
"organization": "", "organization": "",
"last_sign_in_at": "2012-06-01T11:41:01Z", "last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z", "confirmed_at": "2012-05-23T09:05:22Z",
"last_activity_on": "2012-05-23",
"color_scheme_id": 2, "color_scheme_id": 2,
"projects_limit": 100, "projects_limit": 100,
"current_sign_in_at": "2012-06-02T06:36:55Z", "current_sign_in_at": "2012-06-02T06:36:55Z",
...@@ -106,6 +107,7 @@ GET /users ...@@ -106,6 +107,7 @@ GET /users
"organization": "", "organization": "",
"last_sign_in_at": null, "last_sign_in_at": null,
"confirmed_at": "2012-05-30T16:53:06.148Z", "confirmed_at": "2012-05-30T16:53:06.148Z",
"last_activity_on": "2012-05-23",
"color_scheme_id": 3, "color_scheme_id": 3,
"projects_limit": 100, "projects_limit": 100,
"current_sign_in_at": "2014-03-19T17:54:13Z", "current_sign_in_at": "2014-03-19T17:54:13Z",
...@@ -198,6 +200,7 @@ Parameters: ...@@ -198,6 +200,7 @@ Parameters:
"organization": "", "organization": "",
"last_sign_in_at": "2012-06-01T11:41:01Z", "last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z", "confirmed_at": "2012-05-23T09:05:22Z",
"last_activity_on": "2012-05-23",
"color_scheme_id": 2, "color_scheme_id": 2,
"projects_limit": 100, "projects_limit": 100,
"current_sign_in_at": "2012-06-02T06:36:55Z", "current_sign_in_at": "2012-06-02T06:36:55Z",
...@@ -322,6 +325,7 @@ GET /user ...@@ -322,6 +325,7 @@ GET /user
"organization": "", "organization": "",
"last_sign_in_at": "2012-06-01T11:41:01Z", "last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z", "confirmed_at": "2012-05-23T09:05:22Z",
"last_activity_on": "2012-05-23",
"color_scheme_id": 2, "color_scheme_id": 2,
"projects_limit": 100, "projects_limit": 100,
"current_sign_in_at": "2012-06-02T06:36:55Z", "current_sign_in_at": "2012-06-02T06:36:55Z",
...@@ -367,6 +371,7 @@ GET /user ...@@ -367,6 +371,7 @@ GET /user
"organization": "", "organization": "",
"last_sign_in_at": "2012-06-01T11:41:01Z", "last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z", "confirmed_at": "2012-05-23T09:05:22Z",
"last_activity_on": "2012-05-23",
"color_scheme_id": 2, "color_scheme_id": 2,
"projects_limit": 100, "projects_limit": 100,
"current_sign_in_at": "2012-06-02T06:36:55Z", "current_sign_in_at": "2012-06-02T06:36:55Z",
...@@ -999,16 +1004,9 @@ The activities that update the timestamp are: ...@@ -999,16 +1004,9 @@ The activities that update the timestamp are:
- Git HTTP/SSH activities (such as clone, push) - Git HTTP/SSH activities (such as clone, push)
- User logging in into GitLab - User logging in into GitLab
The data is stored in Redis and it depends on it for being recorded and displayed
over time. This means that we will lose the data if Redis gets flushed, or a custom
TTL is reached.
By default, it shows the activity for all users in the last 6 months, but this can be By default, it shows the activity for all users in the last 6 months, but this can be
amended by using the `from` parameter. amended by using the `from` parameter.
This function takes pagination parameters `page` and `per_page` to restrict the list of users.
``` ```
GET /user/activities GET /user/activities
``` ```
...@@ -1029,15 +1027,20 @@ Example response: ...@@ -1029,15 +1027,20 @@ Example response:
[ [
{ {
"username": "user1", "username": "user1",
"last_activity_at": "2015-12-14 01:00:00" "last_activity_on": "2015-12-14",
"last_activity_at": "2015-12-14"
}, },
{ {
"username": "user2", "username": "user2",
"last_activity_at": "2015-12-15 01:00:00" "last_activity_on": "2015-12-15",
"last_activity_at": "2015-12-15"
}, },
{ {
"username": "user3", "username": "user3",
"last_activity_at": "2015-12-16 01:00:00" "last_activity_on": "2015-12-16",
"last_activity_at": "2015-12-16"
} }
] ]
``` ```
Please note that `last_activity_at` is deprecated, please use `last_activity_on`.
...@@ -20,7 +20,8 @@ module API ...@@ -20,7 +20,8 @@ module API
class UserActivity < Grape::Entity class UserActivity < Grape::Entity
expose :username expose :username
expose :last_activity_at expose :last_activity_on
expose :last_activity_on, as: :last_activity_at # Back-compat
end end
class Identity < Grape::Entity class Identity < Grape::Entity
...@@ -30,6 +31,7 @@ module API ...@@ -30,6 +31,7 @@ module API
class UserPublic < User class UserPublic < User
expose :last_sign_in_at expose :last_sign_in_at
expose :confirmed_at expose :confirmed_at
expose :last_activity_on
expose :email expose :email
expose :color_scheme_id, :projects_limit, :current_sign_in_at expose :color_scheme_id, :projects_limit, :current_sign_in_at
expose :identities, using: Entities::Identity expose :identities, using: Entities::Identity
......
...@@ -537,19 +537,17 @@ module API ...@@ -537,19 +537,17 @@ module API
desc 'Get a list of user activities' desc 'Get a list of user activities'
params do params do
optional :from, type: String, desc: 'Date string in the format YEAR-MONTH-DAY' optional :from, type: DateTime, default: 6.months.ago, desc: 'Date string in the format YEAR-MONTH-DAY'
use :pagination use :pagination
end end
get ":activities" do get "activities" do
authenticated_as_admin! authenticated_as_admin!
activity_set = Gitlab::UserActivities::ActivitySet.new(from: params[:from], activities = User.
page: params[:page], where(User.arel_table[:last_activity_on].gteq(params[:from])).
per_page: params[:per_page]) reorder(last_activity_on: :asc)
add_pagination_headers(activity_set) present paginate(activities), with: Entities::UserActivity
present activity_set.activities, with: Entities::UserActivity
end end
end end
end end
......
module Gitlab
class UserActivities
include Enumerable
KEY = 'users:activities'.freeze
BATCH_SIZE = 500
def self.record(key, time = Time.now)
Gitlab::Redis.with do |redis|
redis.hset(KEY, key, time.to_i)
end
end
def delete(*keys)
Gitlab::Redis.with do |redis|
redis.hdel(KEY, keys)
end
end
def each
cursor = 0
loop do
cursor, pairs =
Gitlab::Redis.with do |redis|
redis.hscan(KEY, cursor, count: BATCH_SIZE)
end
Hash[pairs].each { |pair| yield pair }
break if cursor == '0'
end
end
end
end
module Gitlab
module UserActivities
class Activity
attr_reader :username
def initialize(username, time)
@username = username
@time = time
end
def last_activity_at
@last_activity_at ||= Time.at(@time).to_s(:db)
end
end
end
end
module Gitlab
module UserActivities
class ActivitySet
delegate :total_count,
:total_pages,
:current_page,
:limit_value,
:first_page?,
:prev_page,
:last_page?,
:next_page, to: :pagination_delegate
KEY = 'user/activities'.freeze
def self.record(user)
Gitlab::Redis.with do |redis|
redis.zadd(KEY, Time.now.to_i, user.username)
end
end
def initialize(from: nil, page: nil, per_page: nil)
@from = sanitize_date(from)
@to = Time.now.to_i
@page = page
@per_page = per_page
end
def activities
@activities ||= raw_activities.map { |activity| Activity.new(*activity) }
end
private
def sanitize_date(date)
Time.strptime(date, "%Y-%m-%d").to_i
rescue TypeError, ArgumentError
default_from
end
def pagination_delegate
@pagination_delegate ||= Gitlab::PaginationDelegate.new(page: @page,
per_page: @per_page,
count: count)
end
def raw_activities
Gitlab::Redis.with do |redis|
redis.zrangebyscore(KEY, @from, @to, with_scores: true, limit: limit)
end
end
def count
Gitlab::Redis.with do |redis|
redis.zcount(KEY, @from, @to)
end
end
def limit
[pagination_delegate.offset, pagination_delegate.limit_value]
end
def default_from
6.months.ago.to_i
end
end
end
end
...@@ -43,7 +43,7 @@ describe SessionsController do ...@@ -43,7 +43,7 @@ describe SessionsController do
it 'updates the user activity' do it 'updates the user activity' do
expect do expect do
post(:create, user: { login: user.username, password: user.password }) post(:create, user: { login: user.username, password: user.password })
end.to change { user_score }.from(0) end.to change { user_activity(user) }
end end
end end
end end
......
require 'spec_helper'
describe Gitlab::UserActivities::ActivitySet, :redis, lib: true do
let(:user) { create(:user) }
it 'shows the last user activity' do
Timecop.freeze do
user.record_activity
expect(described_class.new.activities.first).to be_an_instance_of(Gitlab::UserActivities::Activity)
end
end
context 'pagination delegation' do
let(:pagination_delegate) do
Gitlab::PaginationDelegate.new(page: 1,
per_page: 10,
count: 20)
end
let(:delegated_methods) { %i[total_count total_pages current_page limit_value first_page? prev_page last_page? next_page] }
before do
allow(described_class.new).to receive(:pagination_delegate).and_return(pagination_delegate)
end
it 'includes the delegated methods' do
expect(described_class.new.public_methods).to include(*delegated_methods)
end
end
context 'paginated activities' do
before do
Timecop.scale(3600)
7.times do
create(:user).record_activity
end
end
after do
Timecop.return
end
it 'shows the 5 oldest user activities paginated' do
expect(described_class.new(per_page: 5).activities.count).to eq(5)
end
it 'shows the 2 reamining user activities paginated' do
expect(described_class.new(per_page: 5, page: 2).activities.count).to eq(2)
end
it 'shows the oldest first' do
activities = described_class.new.activities
expect(activities.first.last_activity_at).to be < activities.last.last_activity_at
end
end
context 'filter by date' do
before do
create(:user).record_activity
end
it 'shows activities from today' do
today = Date.today.to_s("%Y-%m-%d")
expect(described_class.new(from: today).activities.count).to eq(1)
end
it 'filter activities from tomorrow' do
tomorrow = Date.tomorrow.to_s("%Y-%m-%d")
expect(described_class.new(from: tomorrow).activities.count).to eq(0)
end
end
end
require 'spec_helper'
describe Gitlab::UserActivities::Activity, :redis, lib: true do
let(:username) { 'user' }
let(:activity) { described_class.new('user', Time.new(2016, 12, 12).to_i) }
it 'has the username' do
expect(activity.username).to eq(username)
end
it 'has the last activity at' do
expect(activity.last_activity_at).to eq('2016-12-12 00:00:00')
end
end
require 'spec_helper'
describe Gitlab::UserActivities, :redis, lib: true do
let(:now) { Time.now }
describe '.record' do
context 'with no time given' do
it 'uses Time.now and records an activity in Redis' do
Timecop.freeze do
now # eager-load now
described_class.record(42)
end
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]])
end
end
end
context 'with a time given' do
it 'uses the given time and records an activity in Redis' do
described_class.record(42, now)
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]])
end
end
end
end
describe '.delete' do
context 'with a single key' do
context 'and key exists' do
it 'removes the pair from Redis' do
described_class.record(42, now)
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]])
end
subject.delete(42)
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
end
end
context 'and key does not exist' do
it 'removes the pair from Redis' do
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
subject.delete(42)
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
end
end
end
context 'with multiple keys' do
context 'and all keys exist' do
it 'removes the pair from Redis' do
described_class.record(41, now)
described_class.record(42, now)
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['41', now.to_i.to_s], ['42', now.to_i.to_s]]])
end
subject.delete(41, 42)
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
end
end
context 'and some keys does not exist' do
it 'removes the existing pair from Redis' do
described_class.record(42, now)
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]])
end
subject.delete(41, 42)
Gitlab::Redis.with do |redis|
expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []])
end
end
end
end
end
describe 'Enumerable' do
before do
described_class.record(40, now)
described_class.record(41, now)
described_class.record(42, now)
end
it 'allows to read the activities sequentially' do
expected = { '40' => now.to_i.to_s, '41' => now.to_i.to_s, '42' => now.to_i.to_s }
actual = described_class.new.each_with_object({}) do |(key, time), actual|
actual[key] = time
end
expect(actual).to eq(expected)
end
context 'with many records' do
before do
1_000.times { |i| described_class.record(i, now) }
end
it 'is possible to loop through all the records' do
expect(described_class.new.count).to eq(1_000)
end
end
end
end
# encoding: utf-8
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170324160416_migrate_user_activities_to_users_last_activity_on.rb')
describe MigrateUserActivitiesToUsersLastActivityOn, :redis do
let(:migration) { described_class.new }
let!(:user_active_1) { create(:user) }
let!(:user_active_2) { create(:user) }
def record_activity(user, time)
Gitlab::Redis.with do |redis|
redis.zadd(described_class::USER_ACTIVITY_SET_KEY, time.to_i, user.username)
end
end
around do |example|
Timecop.freeze { example.run }
end
before do
record_activity(user_active_1, described_class::TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED + 2.months)
record_activity(user_active_2, described_class::TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED + 3.months)
mute_stdout { migration.up }
end
describe '#up' do
it 'fills last_activity_on from the legacy Redis Sorted Set' do
expect(user_active_1.reload.last_activity_on).to eq((described_class::TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED + 2.months).to_date)
expect(user_active_2.reload.last_activity_on).to eq((described_class::TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED + 3.months).to_date)
end
end
describe '#down' do
it 'sets last_activity_on to NULL for all users' do
mute_stdout { migration.down }
expect(user_active_1.reload.last_activity_on).to be_nil
expect(user_active_2.reload.last_activity_on).to be_nil
end
end
def mute_stdout
orig_stdout = $stdout
$stdout = StringIO.new
yield
$stdout = orig_stdout
end
end
...@@ -196,8 +196,6 @@ describe API::Internal, api: true do ...@@ -196,8 +196,6 @@ describe API::Internal, api: true do
end end
describe "POST /internal/allowed", :redis do describe "POST /internal/allowed", :redis do
include UserActivitiesHelpers
context "access granted" do context "access granted" do
before do before do
project.team << [user, :developer] project.team << [user, :developer]
...@@ -215,7 +213,7 @@ describe API::Internal, api: true do ...@@ -215,7 +213,7 @@ describe API::Internal, api: true do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(json_response["status"]).to be_truthy expect(json_response["status"]).to be_truthy
expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo) expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo)
expect(user_score).to be_zero expect(user).not_to have_an_activity_record
end end
end end
...@@ -226,7 +224,7 @@ describe API::Internal, api: true do ...@@ -226,7 +224,7 @@ describe API::Internal, api: true do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(json_response["status"]).to be_truthy expect(json_response["status"]).to be_truthy
expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo) expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo)
expect(user_score).not_to be_zero expect(user).to have_an_activity_record
end end
end end
...@@ -237,7 +235,7 @@ describe API::Internal, api: true do ...@@ -237,7 +235,7 @@ describe API::Internal, api: true do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(json_response["status"]).to be_truthy expect(json_response["status"]).to be_truthy
expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
expect(user_score).not_to be_zero expect(user).to have_an_activity_record
end end
end end
...@@ -248,7 +246,7 @@ describe API::Internal, api: true do ...@@ -248,7 +246,7 @@ describe API::Internal, api: true do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(json_response["status"]).to be_truthy expect(json_response["status"]).to be_truthy
expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) expect(json_response["repository_path"]).to eq(project.repository.path_to_repo)
expect(user_score).to be_zero expect(user).not_to have_an_activity_record
end end
context 'project as /namespace/project' do context 'project as /namespace/project' do
...@@ -284,7 +282,7 @@ describe API::Internal, api: true do ...@@ -284,7 +282,7 @@ describe API::Internal, api: true do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(json_response["status"]).to be_falsey expect(json_response["status"]).to be_falsey
expect(user_score).to be_zero expect(user).not_to have_an_activity_record
end end
end end
...@@ -294,7 +292,7 @@ describe API::Internal, api: true do ...@@ -294,7 +292,7 @@ describe API::Internal, api: true do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(json_response["status"]).to be_falsey expect(json_response["status"]).to be_falsey
expect(user_score).to be_zero expect(user).not_to have_an_activity_record
end end
end end
end end
...@@ -312,7 +310,7 @@ describe API::Internal, api: true do ...@@ -312,7 +310,7 @@ describe API::Internal, api: true do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(json_response["status"]).to be_falsey expect(json_response["status"]).to be_falsey
expect(user_score).to be_zero expect(user).not_to have_an_activity_record
end end
end end
...@@ -322,7 +320,7 @@ describe API::Internal, api: true do ...@@ -322,7 +320,7 @@ describe API::Internal, api: true do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(json_response["status"]).to be_falsey expect(json_response["status"]).to be_falsey
expect(user_score).to be_zero expect(user).not_to have_an_activity_record
end end
end end
end end
......
...@@ -1171,72 +1171,44 @@ describe API::Users, api: true do ...@@ -1171,72 +1171,44 @@ describe API::Users, api: true do
end end
context "user activities", :redis do context "user activities", :redis do
let!(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) }
let!(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) }
context 'last activity as normal user' do context 'last activity as normal user' do
it 'has no permission' do it 'has no permission' do
user.record_activity
get api("/user/activities", user) get api("/user/activities", user)
expect(response).to have_http_status(403) expect(response).to have_http_status(403)
end end
end end
context 'last activity as admin' do context 'as admin' do
it 'returns the last activity' do it 'returns the activities from the last 6 months' do
allow(Time).to receive(:now).and_return(Time.new(2000, 1, 1))
user.record_activity
get api("/user/activities", admin) get api("/user/activities", admin)
activity = json_response.last
expect(response).to include_pagination_headers expect(response).to include_pagination_headers
expect(activity['username']).to eq(user.username) expect(json_response.size).to eq(1)
expect(activity['last_activity_at']).to eq('2000-01-01 00:00:00')
end
end
context 'last activities paginated', :redis do
let(:activity) { json_response.first }
let(:old_date) { 2.months.ago.to_date }
before do
5.times do |num|
Timecop.freeze(old_date + num)
create(:user, username: num.to_s).record_activity
end
end
after do
Timecop.return
end
it 'returns 3 activities' do
get api("/user/activities?page=1&per_page=3", admin)
expect(json_response.count).to eq(3)
end
it 'contains the first activities' do activity = json_response.last
get api("/user/activities?page=1&per_page=3", admin)
expect(json_response.map { |activity| activity['username'] }).to eq(%w[0 1 2]) expect(activity['username']).to eq(newly_active_user.username)
expect(activity['last_activity_on']).to eq(2.days.ago.to_date.to_s)
expect(activity['last_activity_at']).to eq(2.days.ago.to_date.to_s)
end end
it 'contains the last activities' do context 'passing a :from parameter' do
get api("/user/activities?page=2&per_page=3", admin) it 'returns the activities from the given date' do
get api("/user/activities?from=2000-1-1", admin)
expect(json_response.map { |activity| activity['username'] }).to eq(%w[3 4])
end
it 'contains activities created after user 3 was created' do expect(response).to include_pagination_headers
from = (old_date + 3).to_s("%Y-%m-%d") expect(json_response.size).to eq(2)
get api("/user/activities?page=1&per_page=5&from=#{from}", admin) activity = json_response.first
expect(json_response.map { |activity| activity['username'] }).to eq(%w[3 4]) expect(activity['username']).to eq(old_active_user.username)
expect(activity['last_activity_on']).to eq(Time.utc(2000, 1, 1).to_date.to_s)
expect(activity['last_activity_at']).to eq(Time.utc(2000, 1, 1).to_date.to_s)
end
end end
end end
end end
......
...@@ -227,8 +227,6 @@ describe 'Git HTTP requests', lib: true do ...@@ -227,8 +227,6 @@ describe 'Git HTTP requests', lib: true do
end end
context "when the user isn't blocked", :redis do context "when the user isn't blocked", :redis do
include UserActivitiesHelpers
it "responds with status 200" do it "responds with status 200" do
download(path, env) do |response| download(path, env) do |response|
expect(response.status).to eq(200) expect(response.status).to eq(200)
...@@ -237,7 +235,7 @@ describe 'Git HTTP requests', lib: true do ...@@ -237,7 +235,7 @@ describe 'Git HTTP requests', lib: true do
it 'updates the user last activity' do it 'updates the user last activity' do
download(path, env) do |_response| download(path, env) do |_response|
expect(user_score).not_to be_zero expect(user).to have_an_activity_record
end end
end end
end end
......
...@@ -122,7 +122,7 @@ describe EventCreateService, services: true do ...@@ -122,7 +122,7 @@ describe EventCreateService, services: true do
end end
it 'updates user last activity' do it 'updates user last activity' do
expect { service.push(project, user, {}) }.to change { user_score } expect { service.push(project, user, {}) }.to change { user_activity(user) }
end end
end end
......
...@@ -14,18 +14,18 @@ describe Users::ActivityService, services: true do ...@@ -14,18 +14,18 @@ describe Users::ActivityService, services: true do
end end
it 'sets the last activity timestamp for the user' do it 'sets the last activity timestamp for the user' do
expect(last_hour_members).to eq([user.username]) expect(last_hour_user_ids).to eq([user.id])
end end
it 'updates the same user' do it 'updates the same user' do
service.execute service.execute
expect(last_hour_members).to eq([user.username]) expect(last_hour_user_ids).to eq([user.id])
end end
it 'updates the timestamp of an existing user' do it 'updates the timestamp of an existing user' do
Timecop.freeze(Date.tomorrow) do Timecop.freeze(Date.tomorrow) do
expect { service.execute }.to change { user_score }.to(Time.now.to_i) expect { service.execute }.to change { user_activity(user) }.to(Time.now.to_i.to_s)
end end
end end
...@@ -34,7 +34,7 @@ describe Users::ActivityService, services: true do ...@@ -34,7 +34,7 @@ describe Users::ActivityService, services: true do
other_user = create(:user) other_user = create(:user)
described_class.new(other_user, 'type').execute described_class.new(other_user, 'type').execute
expect(last_hour_members).to match_array([user.username, other_user.username]) expect(last_hour_user_ids).to match_array([user.id, other_user.id])
end end
end end
end end
...@@ -45,8 +45,14 @@ describe Users::ActivityService, services: true do ...@@ -45,8 +45,14 @@ describe Users::ActivityService, services: true do
it 'does not update last_activity_at' do it 'does not update last_activity_at' do
service.execute service.execute
expect(last_hour_members).to eq([]) expect(last_hour_user_ids).to eq([])
end end
end end
end end
def last_hour_user_ids
Gitlab::UserActivities.new.
select { |k, v| v >= 1.hour.ago.to_i.to_s }.
map { |k, _| k.to_i }
end
end end
RSpec::Matchers.define :have_an_activity_record do |expected|
match do |user|
expect(Gitlab::UserActivities.new.find { |k, _| k == user.id.to_s }).to be_present
end
end
module UserActivitiesHelpers module UserActivitiesHelpers
def last_hour_members def user_activity(user)
Gitlab::Redis.with do |redis| Gitlab::UserActivities.new.
redis.zrangebyscore(user_activities_key, 1.hour.ago.to_i, Time.now.to_i) find { |k, _| k == user.id.to_s }&.
end second
end
def user_score
Gitlab::Redis.with do |redis|
redis.zscore(user_activities_key, user.username).to_i
end
end
def user_activities_key
'user/activities'
end end
end end
require 'spec_helper'
describe ScheduleUpdateUserActivityWorker, :redis do
let(:now) { Time.now }
before do
Gitlab::UserActivities.record('1', now)
Gitlab::UserActivities.record('2', now)
end
it 'schedules UpdateUserActivityWorker once' do
expect(UpdateUserActivityWorker).to receive(:perform_async).with({ '1' => now.to_i.to_s, '2' => now.to_i.to_s })
subject.perform
end
context 'when specifying a batch size' do
it 'schedules UpdateUserActivityWorker twice' do
expect(UpdateUserActivityWorker).to receive(:perform_async).with({ '1' => now.to_i.to_s })
expect(UpdateUserActivityWorker).to receive(:perform_async).with({ '2' => now.to_i.to_s })
subject.perform(1)
end
end
end
require 'spec_helper'
describe UpdateUserActivityWorker, :redis do
let(:user_active_2_days_ago) { create(:user, current_sign_in_at: 10.months.ago) }
let(:user_active_yesterday_1) { create(:user) }
let(:user_active_yesterday_2) { create(:user) }
let(:user_active_today) { create(:user) }
let(:data) do
{
user_active_2_days_ago.id.to_s => 2.days.ago.at_midday.to_i.to_s,
user_active_yesterday_1.id.to_s => 1.day.ago.at_midday.to_i.to_s,
user_active_yesterday_2.id.to_s => 1.day.ago.at_midday.to_i.to_s,
user_active_today.id.to_s => Time.now.to_i.to_s
}
end
it 'updates users.last_activity_on' do
subject.perform(data)
aggregate_failures do
expect(user_active_2_days_ago.reload.last_activity_on).to eq(2.days.ago.to_date)
expect(user_active_yesterday_1.reload.last_activity_on).to eq(1.day.ago.to_date)
expect(user_active_yesterday_2.reload.last_activity_on).to eq(1.day.ago.to_date)
expect(user_active_today.reload.reload.last_activity_on).to eq(Date.today)
end
end
it 'deletes the pairs from Redis' do
data.each { |id, time| Gitlab::UserActivities.record(id, time) }
subject.perform(data)
expect(Gitlab::UserActivities.new.to_a).to be_empty
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment