From 6448642477851d3dc7d34e0d11e95cfb5872a39f Mon Sep 17 00:00:00 2001 From: Sebastian Kippe Date: Thu, 1 Sep 2016 17:14:42 +0200 Subject: [PATCH 01/14] Return 503 for PUT/DELETE during user migration --- lib/remote_storage/swift.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/remote_storage/swift.rb b/lib/remote_storage/swift.rb index 59e23e7..3c5bb12 100644 --- a/lib/remote_storage/swift.rb +++ b/lib/remote_storage/swift.rb @@ -20,6 +20,10 @@ module RemoteStorage def authorize_request(user, directory, token, listing=false) request_method = server.env["REQUEST_METHOD"] + if request_method.match(/PUT|DELETE/) && redis.sismember("migration_in_progress", user) + server.halt 503, "Down for maintenance. Back soon!" + end + if directory.split("/").first == "public" return true if ["GET", "HEAD"].include?(request_method) && !listing end From 8ad882d5ab9dce2dbc241f5e31db43b2c3e8336b Mon Sep 17 00:00:00 2001 From: Sebastian Kippe Date: Thu, 1 Sep 2016 18:12:52 +0200 Subject: [PATCH 02/14] If user container doesn't exist, use shared container --- lib/remote_storage/swift.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/remote_storage/swift.rb b/lib/remote_storage/swift.rb index 3c5bb12..fa84347 100644 --- a/lib/remote_storage/swift.rb +++ b/lib/remote_storage/swift.rb @@ -383,7 +383,13 @@ module RemoteStorage end def container_url_for(user) - "#{base_url}/#{container_for(user)}" + user_container_url = "#{base_url}/#{container_for(user)}" + res = do_head_request(user_container_url) + # User before container migration + return user_container_url if res.status == 200 + rescue RestClient::ResourceNotFound + # User after container migration + "#{base_url}/rs:documents:#{settings.environment.to_s}/#{user}" end def url_for_key(user, directory, key) From 49ec6effa7352cdf22690b3308b31ba4c4a53f90 Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Fri, 2 Sep 2016 15:10:01 +0200 Subject: [PATCH 03/14] Remove unused method --- lib/remote_storage/swift.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/remote_storage/swift.rb b/lib/remote_storage/swift.rb index fa84347..1a259d7 100644 --- a/lib/remote_storage/swift.rb +++ b/lib/remote_storage/swift.rb @@ -396,14 +396,6 @@ module RemoteStorage File.join [container_url_for(user), escape(directory), escape(key)].compact end - def url_for_directory(user, directory) - if directory.empty? - container_url_for(user) - else - "#{container_url_for(user)}/#{escape(directory)}" - end - end - def base_url @base_url ||= settings.swift["host"] end From fdc819d53d08e6b97d1f76d993633edae7f0858b Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Fri, 2 Sep 2016 15:15:48 +0200 Subject: [PATCH 04/14] Determine which container to use from Redis --- lib/remote_storage/swift.rb | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/remote_storage/swift.rb b/lib/remote_storage/swift.rb index 1a259d7..978e187 100644 --- a/lib/remote_storage/swift.rb +++ b/lib/remote_storage/swift.rb @@ -20,7 +20,7 @@ module RemoteStorage def authorize_request(user, directory, token, listing=false) request_method = server.env["REQUEST_METHOD"] - if request_method.match(/PUT|DELETE/) && redis.sismember("migration_in_progress", user) + if request_method.match(/PUT|DELETE/) && container_type(user) == "locked" server.halt 503, "Down for maintenance. Back soon!" end @@ -383,13 +383,11 @@ module RemoteStorage end def container_url_for(user) - user_container_url = "#{base_url}/#{container_for(user)}" - res = do_head_request(user_container_url) - # User before container migration - return user_container_url if res.status == 200 - rescue RestClient::ResourceNotFound - # User after container migration - "#{base_url}/rs:documents:#{settings.environment.to_s}/#{user}" + if container_type(user) == "shared" + "#{base_url}/rs:documents:#{settings.environment.to_s}/#{user}" + else + user_container_url + end end def url_for_key(user, directory, key) @@ -404,6 +402,10 @@ module RemoteStorage "rs:#{settings.environment.to_s.chars.first}:#{user}" end + def container_type(user) + redis.get("rs:container:#{user}") || "legacy" + end + def default_headers {"x-auth-token" => swift_token} end From c79b86bff55784b2db7372181d8a76b4a0a77116 Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Fri, 2 Sep 2016 16:41:36 +0200 Subject: [PATCH 05/14] Change Redis key for container migration --- lib/remote_storage/swift.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/remote_storage/swift.rb b/lib/remote_storage/swift.rb index 978e187..c29e888 100644 --- a/lib/remote_storage/swift.rb +++ b/lib/remote_storage/swift.rb @@ -20,7 +20,7 @@ module RemoteStorage def authorize_request(user, directory, token, listing=false) request_method = server.env["REQUEST_METHOD"] - if request_method.match(/PUT|DELETE/) && container_type(user) == "locked" + if request_method.match(/PUT|DELETE/) && container_migration(user) == "in_progress" server.halt 503, "Down for maintenance. Back soon!" end @@ -383,10 +383,10 @@ module RemoteStorage end def container_url_for(user) - if container_type(user) == "shared" - "#{base_url}/rs:documents:#{settings.environment.to_s}/#{user}" - else + if container_migration(user) user_container_url + else + "#{base_url}/rs:documents:#{settings.environment.to_s}/#{user}" end end @@ -402,8 +402,8 @@ module RemoteStorage "rs:#{settings.environment.to_s.chars.first}:#{user}" end - def container_type(user) - redis.get("rs:container:#{user}") || "legacy" + def container_migration(user) + redis.get("rs:container_migration:#{user}") end def default_headers From 22ce52d00ccf496f886ed74fd7c407b6ae1cf28b Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Fri, 2 Sep 2016 18:30:13 +0200 Subject: [PATCH 06/14] Migration for moving to a single shared container for all users --- migrate_to_single_container.rb | 180 +++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100755 migrate_to_single_container.rb diff --git a/migrate_to_single_container.rb b/migrate_to_single_container.rb new file mode 100755 index 0000000..c29270e --- /dev/null +++ b/migrate_to_single_container.rb @@ -0,0 +1,180 @@ +#!/usr/bin/env ruby + +require "rubygems" +require "bundler/setup" +require "rest_client" +require "redis" +require "yaml" +require "logger" +require "json" +require "active_support/core_ext/hash" + +class Migrator + + attr_accessor :username, :base_url, :swift_host, :swift_token, + :environment, :dry_run, :settings, :logger + + def initialize(username) + @username = username + + @environment = ENV["ENVIRONMENT"] || "staging" + @settings = YAML.load(File.read('config.yml'))[@environment] + + @swift_host = @settings["swift"]["host"] + @swift_token = File.read("tmp/swift_token.txt").strip + + @dry_run = ENV["DRYRUN"] || false # disables writing anything when true + + @logger = Logger.new("log/migrate_to_single_container.log") + log_level = ENV["LOGLEVEL"] || "INFO" + logger.level = Kernel.const_get "Logger::#{log_level}" + logger.progname = username + end + + def migrate + logger.info "Starting migration for '#{username}'" + set_container_migration_state("in_progress") + begin + work_on_dir("", "") + rescue Exception => ex + logger.error "Error migrating documents for '#{username}': #{ex}" + set_container_migration_state("not_started") + # write username to file for later reference + File.open('log/failed_migration.log', 'a') { |f| f.puts username } + exit 1 + end + delete_container_migration_state + File.open('log/finished_migration.log', 'a') { |f| f.puts username } + logger.info "Finished migration for '#{username}'" + end + + def is_dir?(name) + name[-1] == "/" + end + + def set_container_migration_state(type) + redis.set("rs:container_migration:#{username}", type) unless dry_run + end + + def delete_container_migration_state + redis.del("rs:container_migration:#{username}") unless dry_run + end + + def work_on_dir(directory, parent_directory) + logger.debug "Retrieving listing for '#{parent_directory}#{directory}'" + + listing = get_directory_listing_from_swift("#{parent_directory}#{directory}") + + if listing + listing.split("\n").each do |item| + if is_dir? item + # get dir listing and repeat + work_on_dir(item, "#{parent_directory}") + else + copy_document("#{parent_directory}", item) + end + end + end + end + + def copy_document(directory, document) + old_document_url = "#{url_for_directory(@username, directory)}/#{escape(document)}" + new_document_url = "#{new_url_for_directory(@username, directory)}/#{escape(document)}" + + logger.debug "Copying document from #{old_document_url} to #{new_document_url}" + + response = do_get_request(old_document_url) + + unless dry_run + do_put_request(new_document_url, response.body, response.headers[:content_type]) + end + end + + def redis + @redis ||= Redis.new(@settings["redis"].symbolize_keys) + end + + def get_directory_listing_from_swift(directory) + is_root_listing = directory.empty? + + get_response = nil + + do_head_request("#{url_for_directory(@username, directory)}") do |response| + return "" if response.code == 404 + + if is_root_listing + get_response = do_get_request("#{container_url_for(@username)}/?path=") + else + get_response = do_get_request("#{container_url_for(@username)}/?path=#{escape(directory)}") + end + end + + get_response.body + end + + def do_head_request(url, &block) + RestClient.head(url, default_headers, &block) + end + + def do_get_request(url, &block) + RestClient.get(url, default_headers, &block) + end + + def do_put_request(url, data, content_type) + RestClient.put(url, data, default_headers.merge({content_type: content_type})) + end + + def default_headers + {"x-auth-token" => @swift_token} + end + + def url_for_directory(user, directory) + if directory.empty? + container_url_for(user) + else + "#{container_url_for(user)}/#{escape(directory)}" + end + end + + def container_url_for(user) + "#{base_url}/#{container_for(user)}" + end + + def new_container_url_for(user) + "#{base_url}/rs:documents:#{environment.to_s.downcase}/#{user}" + end + + def new_url_for_directory(user, directory) + if directory.empty? + new_container_url_for(user) + else + "#{new_container_url_for(user)}/#{escape(directory)}" + end + end + + def base_url + @base_url ||= @swift_host + end + + def container_for(user) + "rs:#{environment.to_s.chars.first}:#{user}" + end + + def escape(url) + # We want spaces to turn into %20 and slashes to stay slashes + CGI::escape(url).gsub('+', '%20').gsub('%2F', '/') + end +end + +username = ARGV[0] + +unless username + puts "No username given." + puts "Usage:" + puts "ENVIRONMENT=staging ./migrate_to_single_container.rb " + exit 1 +end + +migrator = Migrator.new username +migrator.migrate + From e6fa6ca586eb3e0a38aea1797ca9376e2f2ead20 Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Mon, 5 Sep 2016 18:27:35 +0200 Subject: [PATCH 07/14] User proper container and path based on migration state --- lib/remote_storage/swift.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/remote_storage/swift.rb b/lib/remote_storage/swift.rb index c29e888..44721f5 100644 --- a/lib/remote_storage/swift.rb +++ b/lib/remote_storage/swift.rb @@ -384,9 +384,9 @@ module RemoteStorage def container_url_for(user) if container_migration(user) - user_container_url + "#{base_url}/rs:#{settings.environment.to_s.chars.first}:#{user}" else - "#{base_url}/rs:documents:#{settings.environment.to_s}/#{user}" + "#{base_url}/#{container_for(user)}" end end @@ -399,7 +399,7 @@ module RemoteStorage end def container_for(user) - "rs:#{settings.environment.to_s.chars.first}:#{user}" + "rs:documents:#{settings.environment.to_s}/#{user}" end def container_migration(user) From 6b7bb8144e46e45f39089709b6381fee7ab376ed Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Tue, 6 Sep 2016 16:24:12 +0200 Subject: [PATCH 08/14] Fix migration script to work without dir objects --- migrate_to_single_container.rb | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/migrate_to_single_container.rb b/migrate_to_single_container.rb index c29270e..4c9f83f 100755 --- a/migrate_to_single_container.rb +++ b/migrate_to_single_container.rb @@ -61,15 +61,18 @@ class Migrator end def work_on_dir(directory, parent_directory) - logger.debug "Retrieving listing for '#{parent_directory}#{directory}'" + full_directory = "#{parent_directory}#{directory}" + logger.debug "Retrieving listing for '#{full_directory}'" - listing = get_directory_listing_from_swift("#{parent_directory}#{directory}") + listing = get_directory_listing_from_swift("#{full_directory}") + + logger.debug "Listing for '#{full_directory}': #{listing}" if listing listing.split("\n").each do |item| if is_dir? item # get dir listing and repeat - work_on_dir(item, "#{parent_directory}") + work_on_dir(item, "#{parent_directory}") unless item == full_directory else copy_document("#{parent_directory}", item) end @@ -99,14 +102,10 @@ class Migrator get_response = nil - do_head_request("#{url_for_directory(@username, directory)}") do |response| - return "" if response.code == 404 - - if is_root_listing - get_response = do_get_request("#{container_url_for(@username)}/?path=") - else - get_response = do_get_request("#{container_url_for(@username)}/?path=#{escape(directory)}") - end + if is_root_listing + get_response = do_get_request("#{container_url_for(@username)}/?delimiter=/&prefix=") + else + get_response = do_get_request("#{container_url_for(@username)}/?delimiter=/&prefix=#{escape(directory)}") end get_response.body From 90a6753d880f36ac22343c1c8a3f5d5e8cdaaf85 Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Tue, 6 Sep 2016 16:36:43 +0200 Subject: [PATCH 09/14] Use container path directly, instead of hiding it behind a method --- lib/remote_storage/swift.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/remote_storage/swift.rb b/lib/remote_storage/swift.rb index 44721f5..467cd44 100644 --- a/lib/remote_storage/swift.rb +++ b/lib/remote_storage/swift.rb @@ -386,7 +386,7 @@ module RemoteStorage if container_migration(user) "#{base_url}/rs:#{settings.environment.to_s.chars.first}:#{user}" else - "#{base_url}/#{container_for(user)}" + "#{base_url}/rs:documents:#{settings.environment.to_s}/#{user}" end end @@ -398,10 +398,6 @@ module RemoteStorage @base_url ||= settings.swift["host"] end - def container_for(user) - "rs:documents:#{settings.environment.to_s}/#{user}" - end - def container_migration(user) redis.get("rs:container_migration:#{user}") end From ad8a75a0ad61e5616c542b2def2615ae3585a603 Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Wed, 7 Sep 2016 14:07:13 +0200 Subject: [PATCH 10/14] Use COPY method instead of GET and PUT --- migrate_to_single_container.rb | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/migrate_to_single_container.rb b/migrate_to_single_container.rb index 4c9f83f..7b8e18c 100755 --- a/migrate_to_single_container.rb +++ b/migrate_to_single_container.rb @@ -82,15 +82,12 @@ class Migrator def copy_document(directory, document) old_document_url = "#{url_for_directory(@username, directory)}/#{escape(document)}" - new_document_url = "#{new_url_for_directory(@username, directory)}/#{escape(document)}" - logger.debug "Copying document from #{old_document_url} to #{new_document_url}" + full_path = [directory, document].reject(&:empty?).join('/') + new_document_path = "rs:documents:#{environment.to_s.downcase}/#{@username}/#{escape(full_path)}" - response = do_get_request(old_document_url) - - unless dry_run - do_put_request(new_document_url, response.body, response.headers[:content_type]) - end + logger.debug "Copying document from #{old_document_url} to #{new_document_path}" + do_copy_request(old_document_url, new_document_path) unless dry_run end def redis @@ -123,6 +120,14 @@ class Migrator RestClient.put(url, data, default_headers.merge({content_type: content_type})) end + def do_copy_request(url, destination_path) + RestClient::Request.execute( + method: :copy, + url: url, + headers: default_headers.merge({destination: destination_path}) + ) + end + def default_headers {"x-auth-token" => @swift_token} end @@ -139,18 +144,6 @@ class Migrator "#{base_url}/#{container_for(user)}" end - def new_container_url_for(user) - "#{base_url}/rs:documents:#{environment.to_s.downcase}/#{user}" - end - - def new_url_for_directory(user, directory) - if directory.empty? - new_container_url_for(user) - else - "#{new_container_url_for(user)}/#{escape(directory)}" - end - end - def base_url @base_url ||= @swift_host end From 685c82d0684de652f612b463323aaf7cb42069e0 Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Wed, 7 Sep 2016 17:05:27 +0200 Subject: [PATCH 11/14] Use Redis hash for storing migration state instead of one key per user --- migrate_to_single_container.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrate_to_single_container.rb b/migrate_to_single_container.rb index 7b8e18c..fa76a3d 100755 --- a/migrate_to_single_container.rb +++ b/migrate_to_single_container.rb @@ -53,11 +53,11 @@ class Migrator end def set_container_migration_state(type) - redis.set("rs:container_migration:#{username}", type) unless dry_run + redis.hset("rs:container_migration", username, type) unless dry_run end def delete_container_migration_state - redis.del("rs:container_migration:#{username}") unless dry_run + redis.hdel("rs:container_migration", username) unless dry_run end def work_on_dir(directory, parent_directory) From 710657748b1309dd6c25beb7d421a7ae1baf0e7e Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Wed, 7 Sep 2016 17:59:14 +0200 Subject: [PATCH 12/14] Use full dir listing instead of per subdir --- migrate_to_single_container.rb | 57 +++++++++------------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/migrate_to_single_container.rb b/migrate_to_single_container.rb index fa76a3d..049c0f0 100755 --- a/migrate_to_single_container.rb +++ b/migrate_to_single_container.rb @@ -35,7 +35,7 @@ class Migrator logger.info "Starting migration for '#{username}'" set_container_migration_state("in_progress") begin - work_on_dir("", "") + copy_all_documents rescue Exception => ex logger.error "Error migrating documents for '#{username}': #{ex}" set_container_migration_state("not_started") @@ -48,8 +48,8 @@ class Migrator logger.info "Finished migration for '#{username}'" end - def is_dir?(name) - name[-1] == "/" + def is_document?(name) + name[-1] != "/" end def set_container_migration_state(type) @@ -60,31 +60,26 @@ class Migrator redis.hdel("rs:container_migration", username) unless dry_run end - def work_on_dir(directory, parent_directory) - full_directory = "#{parent_directory}#{directory}" - logger.debug "Retrieving listing for '#{full_directory}'" + def copy_all_documents + logger.debug "Retrieving object listing" - listing = get_directory_listing_from_swift("#{full_directory}") + listing = get_directory_listing_from_swift - logger.debug "Listing for '#{full_directory}': #{listing}" + logger.debug "Full listing: #{listing}" if listing listing.split("\n").each do |item| - if is_dir? item - # get dir listing and repeat - work_on_dir(item, "#{parent_directory}") unless item == full_directory - else - copy_document("#{parent_directory}", item) + if is_document? item + copy_document(item) end end end end - def copy_document(directory, document) - old_document_url = "#{url_for_directory(@username, directory)}/#{escape(document)}" + def copy_document(document_path) + old_document_url = "#{container_url_for(@username)}/#{escape(document_path)}" - full_path = [directory, document].reject(&:empty?).join('/') - new_document_path = "rs:documents:#{environment.to_s.downcase}/#{@username}/#{escape(full_path)}" + new_document_path = "rs:documents:#{environment.to_s.downcase}/#{@username}/#{escape(document_path)}" logger.debug "Copying document from #{old_document_url} to #{new_document_path}" do_copy_request(old_document_url, new_document_path) unless dry_run @@ -94,32 +89,16 @@ class Migrator @redis ||= Redis.new(@settings["redis"].symbolize_keys) end - def get_directory_listing_from_swift(directory) - is_root_listing = directory.empty? - - get_response = nil - - if is_root_listing - get_response = do_get_request("#{container_url_for(@username)}/?delimiter=/&prefix=") - else - get_response = do_get_request("#{container_url_for(@username)}/?delimiter=/&prefix=#{escape(directory)}") - end + def get_directory_listing_from_swift + get_response = do_get_request("#{container_url_for(@username)}/?prefix=") get_response.body end - def do_head_request(url, &block) - RestClient.head(url, default_headers, &block) - end - def do_get_request(url, &block) RestClient.get(url, default_headers, &block) end - def do_put_request(url, data, content_type) - RestClient.put(url, data, default_headers.merge({content_type: content_type})) - end - def do_copy_request(url, destination_path) RestClient::Request.execute( method: :copy, @@ -132,14 +111,6 @@ class Migrator {"x-auth-token" => @swift_token} end - def url_for_directory(user, directory) - if directory.empty? - container_url_for(user) - else - "#{container_url_for(user)}/#{escape(directory)}" - end - end - def container_url_for(user) "#{base_url}/#{container_for(user)}" end From 74428408b1960318ff7ea0d8d42138529f31209a Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Wed, 7 Sep 2016 18:13:52 +0200 Subject: [PATCH 13/14] Use new Redis migration hash in Liquor Cabinet itself --- lib/remote_storage/swift.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/remote_storage/swift.rb b/lib/remote_storage/swift.rb index 467cd44..4c9ed0e 100644 --- a/lib/remote_storage/swift.rb +++ b/lib/remote_storage/swift.rb @@ -399,7 +399,7 @@ module RemoteStorage end def container_migration(user) - redis.get("rs:container_migration:#{user}") + redis.hget("rs:container_migration", user) end def default_headers From 41baecbf35d9018ae11ebfd85b8f0c847ea4d7c0 Mon Sep 17 00:00:00 2001 From: Garret Alfert Date: Wed, 7 Sep 2016 19:58:32 +0200 Subject: [PATCH 14/14] Use a MigrationRunner to iterate over all unmigrated users --- migrate_to_single_container.rb | 46 ++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/migrate_to_single_container.rb b/migrate_to_single_container.rb index 049c0f0..35d5827 100755 --- a/migrate_to_single_container.rb +++ b/migrate_to_single_container.rb @@ -129,15 +129,45 @@ class Migrator end end -username = ARGV[0] +class MigrationRunner + + attr_accessor :environment, :settings + + def initialize + @environment = ENV["ENVIRONMENT"] || "staging" + @settings = YAML.load(File.read('config.yml'))[@environment] + end + + def migrate + while username = pick_unmigrated_user + migrator = Migrator.new username + migrator.migrate + end + end + + def unmigrated_users + redis.hgetall("rs:container_migration").select { |_, value| + value == "not_started" + }.keys + end + + def pick_unmigrated_user + unmigrated_users.sample # pick a random user from list + end + + def redis + @redis ||= Redis.new(@settings["redis"].symbolize_keys) + end -unless username - puts "No username given." - puts "Usage:" - puts "ENVIRONMENT=staging ./migrate_to_single_container.rb " - exit 1 end -migrator = Migrator.new username -migrator.migrate +username = ARGV[0] + +if username + migrator = Migrator.new username + migrator.migrate +else + runner = MigrationRunner.new + runner.migrate +end