This is posted as a new answer, because it is a completely different approach to my previous answer and includes a benchmark comparison with makeshift and scalable datasets.
First, a table taxi_distances of available taxi for each city is created by joining city_distance and taxi_manage. It is ordered by city and distance using setkey(). This is computed once and avoids repeated joins within the main loop.
Then, taxi_request is ordered by city and time using setkey(). It loops over cities and requests and considers the most recent requests within one 1 hour before of the actual request using a non-equi self join of taxi_request. Each subset of requests shows the taxi which are already assigned. From the sublist of available taxi for the actual city the nearest available taxi is picked using an anti-join (kind of setdiff()) and is assigned to the actual request using update by reference (with special treatment in case there is no nearest available taxi). The update by reference is important here because it conveys the information of which taxi are assigned into the next iteration.
The non-equi self join returns an empty table but taxi_request has been updated as a side-effect.
library(data.table)
options(datatable.print.class = TRUE)
taxi_distances <- city_distance[taxi_manage, on = .(city_2=city), nomatch = NULL]
setkey(taxi_distances, city, distance)
setkey(taxi_request, city, time)
on_duty_duration <- 1L * 60L *60L # 1 hour in seconds
taxi_request[, time_before := time - on_duty_duration]
taxi_request[
taxi_request, on = .(city, time <= time, time >= time_before), by = .EACHI,
{
print(.SD) # for demonstration
nearest_available_taxi <-
taxi_distances[x.city][!.SD, on = .(taxi_number = assigned_taxi),
first(taxi_number)]
print(nearest_available_taxi) # for demonstration
if (length(nearest_available_taxi) > 0)
taxi_request[i.id, assigned_taxi := nearest_available_taxi]
NULL
}
]
id assigned_taxi time_before
<int> <char> <ITime>
1: 1 <NA> -00:35:00
[1] "T001000"
id assigned_taxi time_before
<int> <char> <ITime>
1: 1 T001000 -00:35:00
2: 2 <NA> -00:10:00
character(0)
id assigned_taxi time_before
<int> <char> <ITime>
1: 1 T001000 -00:35:00
2: 2 <NA> -00:10:00
3: 3 <NA> 00:15:00
character(0)
id assigned_taxi time_before
<int> <char> <ITime>
1: 2 <NA> -00:10:00
2: 3 <NA> 00:15:00
3: 4 <NA> 00:40:00
[1] "T001000"
id assigned_taxi time_before
<int> <char> <ITime>
1: 3 <NA> 00:15:00
2: 4 T001000 00:40:00
3: 5 <NA> 01:05:00
character(0)
id assigned_taxi time_before
<int> <char> <ITime>
1: 6 <NA> -00:35:00
[1] "T002000"
id assigned_taxi time_before
<int> <char> <ITime>
1: 6 T002000 -00:35:00
2: 7 <NA> -00:10:00
[1] "T002001"
id assigned_taxi time_before
<int> <char> <ITime>
1: 6 T002000 -00:35:00
2: 7 T002001 -00:10:00
3: 8 <NA> 00:15:00
character(0)
id assigned_taxi time_before
<int> <char> <ITime>
1: 7 T002001 -00:10:00
2: 8 <NA> 00:15:00
3: 9 <NA> 00:40:00
[1] "T002000"
id assigned_taxi time_before
<int> <char> <ITime>
1: 8 <NA> 00:15:00
2: 9 T002000 00:40:00
3: 10 <NA> 01:05:00
[1] "T002001"
id assigned_taxi time_before
<int> <char> <ITime>
1: 11 <NA> -00:35:00
[1] "T003000"
id assigned_taxi time_before
<int> <char> <ITime>
1: 11 T003000 -00:35:00
2: 12 <NA> -00:10:00
[1] "T003001"
id assigned_taxi time_before
<int> <char> <ITime>
1: 11 T003000 -00:35:00
2: 12 T003001 -00:10:00
3: 13 <NA> 00:15:00
[1] "T003002"
id assigned_taxi time_before
<int> <char> <ITime>
1: 12 T003001 -00:10:00
2: 13 T003002 00:15:00
3: 14 <NA> 00:40:00
[1] "T003000"
id assigned_taxi time_before
<int> <char> <ITime>
1: 13 T003002 00:15:00
2: 14 T003000 00:40:00
3: 15 <NA> 01:05:00
[1] "T003001"
Empty data.table (0 rows and 5 cols): city,time,time,assigned_taxi,time_before
taxi_request
id city time assigned_taxi time_before
<int> <char> <ITime> <char> <ITime>
1: 1 C001000 00:25:00 T001000 -00:35:00
2: 2 C001000 00:50:00 <NA> -00:10:00
3: 3 C001000 01:15:00 <NA> 00:15:00
4: 4 C001000 01:40:00 T001000 00:40:00
5: 5 C001000 02:05:00 <NA> 01:05:00
6: 6 C002000 00:25:00 T002000 -00:35:00
7: 7 C002000 00:50:00 T002001 -00:10:00
8: 8 C002000 01:15:00 <NA> 00:15:00
9: 9 C002000 01:40:00 T002000 00:40:00
10: 10 C002000 02:05:00 T002001 01:05:00
11: 11 C003000 00:25:00 T003000 -00:35:00
12: 12 C003000 00:50:00 T003001 -00:10:00
13: 13 C003000 01:15:00 T003002 00:15:00
14: 14 C003000 01:40:00 T003000 00:40:00
15: 15 C003000 02:05:00 T003001 01:05:00
Data
As I do not have a access to OP's production data, I have created a dummy dataset consisting of 3 cities, 6 taxi, 15 requests, and a constant request interval of 25 minutes (2.4 requests per hour). In order to cover several uses cases, the 1st city has one taxi, the 2nd city 2 taxi, etc. So, there will be a fews requests for which no taxi is available.
req_interval <- 25L * 60L # seconds
n_city <- 3L
n_taxi <- seq(n_city)
n_req_per_city <- 5L
stopifnot(n_req_per_city * req_interval <= as.ITime("23:59:59"))
n_req <- n_req_per_city * n_city
tmp <- unlist(Vectorize(rep.int)(n_taxi, n_taxi))
fmt <- "C%03i%03i"
city_distance <- data.table(
city = sprintf(fmt, tmp, 0L),
city_2 = sprintf(fmt, tmp, rowid(tmp) - 1L),
distance = rowid(tmp) - 1L
)
fmt <- "T%03i%03i"
taxi_manage <- data.table(
taxi_number = sprintf(fmt, tmp, rowid(tmp) - 1L),
city = city_distance$city_2,
time_end = as.ITime(rep("00:00", length(tmp))),
ride_count = rep(0L, length(tmp))
)
taxi_request <- data.table(
id = seq(n_req),
city = rep(unique(city_distance$city), each = n_req_per_city),
time = as.ITime(req_interval * rep((seq(n_req_per_city)), n_city)),
assigned_taxi = rep(NA_character_, n_req)
)
city_distance
city city_2 distance
<char> <char> <int>
1: C001000 C001000 0
2: C002000 C002000 0
3: C002000 C002001 1
4: C003000 C003000 0
5: C003000 C003001 1
6: C003000 C003002 2
taxi_manage
taxi_number city time_end ride_count
<char> <char> <ITime> <int>
1: T001000 C001000 00:00:00 0
2: T002000 C002000 00:00:00 0
3: T002001 C002001 00:00:00 0
4: T003000 C003000 00:00:00 0
5: T003001 C003001 00:00:00 0
6: T003002 C003002 00:00:00 0
taxi_request
id city time assigned_taxi
<int> <char> <ITime> <char>
1: 1 C001000 00:25:00 <NA>
2: 2 C001000 00:50:00 <NA>
3: 3 C001000 01:15:00 <NA>
4: 4 C001000 01:40:00 <NA>
5: 5 C001000 02:05:00 <NA>
6: 6 C002000 00:25:00 <NA>
7: 7 C002000 00:50:00 <NA>
8: 8 C002000 01:15:00 <NA>
9: 9 C002000 01:40:00 <NA>
10: 10 C002000 02:05:00 <NA>
11: 11 C003000 00:25:00 <NA>
12: 12 C003000 00:50:00 <NA>
13: 13 C003000 01:15:00 <NA>
14: 14 C003000 01:40:00 <NA>
15: 15 C003000 02:05:00 <NA>
Benchmark
The benchmark compares 5 variants
- The
for loop approach from my previous answer
- The code variant using grouping
by = id from my previous answer
- The performance improvement which pulls one join out of the loop from my previous answer
- The self-join approach from this answer (with an edit to avoid a large cartesian join)
- The self-join approach from this answer but using
setdiff() instead of the anti-join
The runs were performed for three different use cases, all with 10000 requests:
- n_city: 1, n_taxi: 1, n_request: 10000, req_interval: 8
- n_city: 10, n_taxi: 55, n_request: 10000, req_interval: 86
- n_city: 100, n_taxi: 5050, n_request: 10000, req_interval: 863
As can bee seen from the chart below, variant 5 is 3 to 5 times faster than variant 1:

In addition, variant 5 allocates less memory than any other variant. Variant 1 allocates more than 5 times more memory than variant 5 as can be seen from the printed benchmark results:
expression n_req n_city min median `itr/sec` mem_alloc `gc/sec` n_itr n_gc total_time result memory time
<bch:expr> <int> <int> <bch:> <bch:> <dbl> <bch:byt> <dbl> <int> <dbl> <bch:tm> <list> <list> <lis>
1 v1 10000 1 30.41s 31.19s 0.0318 1.97GB 1.42 3 134 1.57m <data… <Rpro… <ben…
2 v2 10000 1 33.42s 33.62s 0.0296 2.43GB 1.12 3 114 1.69m <data… <Rpro… <ben…
3 v3 10000 1 19.67s 19.98s 0.0503 1.41GB 1.14 3 68 59.63s <data… <Rpro… <ben…
4 v4 10000 1 18.43s 18.89s 0.0532 1.58GB 1.15 3 65 56.39s <data… <Rpro… <ben…
5 v5 10000 1 11.15s 11.18s 0.0889 1.17GB 1.39 3 47 33.76s <data… <Rpro… <ben…
6 v1 10000 10 34.72s 34.81s 0.0278 2.3GB 0.815 3 88 1.8m <data… <Rpro… <ben…
7 v2 10000 10 36.43s 36.65s 0.0272 2.64GB 0.645 3 71 1.83m <data… <Rpro… <ben…
8 v3 10000 10 18.74s 19.44s 0.0507 1.45GB 0.659 3 39 59.19s <data… <Rpro… <ben…
9 v4 10000 10 18.38s 18.54s 0.0536 1.52GB 0.679 3 38 55.99s <data… <Rpro… <ben…
10 v5 10000 10 11.53s 11.69s 0.0858 1.13GB 0.801 3 28 34.96s <data… <Rpro… <ben…
11 v1 10000 100 1.15m 1.18m 0.0142 7.88GB 0.356 3 75 3.52m <data… <Rpro… <ben…
12 v2 10000 100 59.21s 1m 0.0167 7.25GB 0.300 3 54 3m <data… <Rpro… <ben…
13 v3 10000 100 23.71s 24.19s 0.0402 2.71GB 0.429 3 32 1.24m <data… <Rpro… <ben…
14 v4 10000 100 21.77s 22.12s 0.0451 1.68GB 0.496 3 33 1.11m <data… <Rpro… <ben…
15 v5 10000 100 15.13s 15.21s 0.0652 1.29GB 0.565 3 26 46.02s <data… <Rpro… <ben…
Benchmark code
library(data.table)
library(bench)
bm2 <- press(
n_req = c(10000L),
n_city = c(1L, 10L, 100L),
{
n_taxi <- seq(n_city)
n_req_per_city <- n_req / n_city
req_interval <-
as.integer(as.ITime("23:59:59") / n_req_per_city)
stopifnot(n_req_per_city * req_interval <= as.ITime("23:59:59"))
tmp <- unlist(Vectorize(rep.int)(n_taxi, n_taxi))
fmt <- "C%04i%04i"
city_distance <- data.table(
city = sprintf(fmt, tmp, 0L),
city_2 = sprintf(fmt, tmp, rowid(tmp) - 1L),
distance = rowid(tmp) - 1L
)
# city_distance
fmt <- "T%04i%04i"
taxi_manage0 <- data.table(
taxi_number = sprintf(fmt, tmp, rowid(tmp) - 1L),
city = city_distance$city_2,
time_end = as.ITime(rep("00:00", length(tmp))),
ride_count = rep(0L, length(tmp))
)
# taxi_manage
taxi_request0 <- data.table(
id = seq(n_req),
city = rep(unique(city_distance$city), each = n_req_per_city),
time = as.ITime(req_interval * rep((seq(n_req_per_city)), n_city)),
assigned_taxi = rep(NA_character_, n_req)
)
# taxi_request0
cat(sprintf("\nn_city: %i, n_taxi: %i, n_request: %i, req_interval: %i\n",
uniqueN(city_distance$city),
uniqueN(taxi_manage0$taxi_number),
nrow(taxi_request0),
req_interval))
mark(
v1 = {
taxi_manage <- copy(taxi_manage0)
taxi_request <- copy(taxi_request0)
on_duty_duration <- 1L * 60L *60L # 1 hour in seconds
setkey(taxi_manage, taxi_number)
setkey(taxi_request, id)
for (req_id in taxi_request$id) {
available_taxi <- taxi_manage[taxi_request[req_id], on = .(time_end < time)]
nearest_available_taxi <-
city_distance[available_taxi, on = .(city = i.city, city_2 = city),
nomatch = NULL][
, taxi_number[which.min(distance)]]
if(length(nearest_available_taxi) > 0) {
taxi_request[req_id, assigned_taxi := nearest_available_taxi]
taxi_manage[nearest_available_taxi,
time_end := taxi_request[req_id, time + on_duty_duration]]
}
}
taxi_request
},
v2 = {
taxi_manage <- copy(taxi_manage0)
taxi_request <- copy(taxi_request0)
on_duty_duration <- 1L * 60L *60L # 1 hour in seconds
setkey(taxi_manage, taxi_number)
taxi_request[, assigned_taxi := {
available_taxi <- taxi_manage[.SD, on = .(time_end < time)]
nearest_available_taxi <-
city_distance[available_taxi, on = .(city = i.city, city_2 = city),
nomatch = NULL][
, taxi_number[which.min(distance)]]
request_time <- .SD$time
taxi_manage[nearest_available_taxi,
time_end := request_time + on_duty_duration]
if (length(nearest_available_taxi) > 0) nearest_available_taxi else NA
},
by = id]
taxi_request
},
v3 = {
taxi_manage <- copy(taxi_manage0)
taxi_request <- copy(taxi_request0)
taxi_distances <- city_distance[taxi_manage, on = .(city_2=city), nomatch = NULL]
setkey(taxi_distances, taxi_number)
on_duty_duration <- 1L * 60L *60L # 1 hour in seconds
taxi_request[, assigned_taxi := {
nearest_available_taxi <-
.SD[taxi_distances, on = .(city, time > time_end),
nomatch = NULL, taxi_number[which.min(distance)]]
request_time <- .SD$time
taxi_distances[nearest_available_taxi,
time_end := request_time + on_duty_duration]
if (length(nearest_available_taxi) > 0) nearest_available_taxi else NA
},
by = id]
taxi_request[]
},
v4 = {
taxi_manage <- copy(taxi_manage0)
taxi_request <- copy(taxi_request0)
taxi_distances <- city_distance[taxi_manage, on = .(city_2=city), nomatch = NULL]
setkey(taxi_distances, city, distance)
setkey(taxi_request, city, time)
on_duty_duration <- 1L * 60L *60L # 1 hour in seconds
taxi_request[, time_before := time - on_duty_duration]
taxi_request[
taxi_request, on = .(city, time <= time, time >= time_before), by = .EACHI,
{
nearest_available_taxi <-
taxi_distances[first(x.city)][!.SD, on = .(taxi_number = assigned_taxi),
first(taxi_number)]
if (length(nearest_available_taxi) > 0)
taxi_request[i.id, assigned_taxi := nearest_available_taxi]
NULL
}
]
taxi_request[, time_before := NULL][]
},
v5 = {
taxi_manage <- copy(taxi_manage0)
taxi_request <- copy(taxi_request0)
taxi_distances <- city_distance[taxi_manage, on = .(city_2=city), nomatch = NULL]
setkey(taxi_distances, city, distance)
setkey(taxi_request, city, time)
on_duty_duration <- 1L * 60L *60L # 1 hour in seconds
taxi_request[, time_before := time - on_duty_duration]
taxi_request[
taxi_request, on = .(city, time <= time, time >= time_before), by = .EACHI,
{
nearest_available_taxi <- first(setdiff(
taxi_distances[first(x.city), taxi_number],
.SD[, assigned_taxi]))
if (length(nearest_available_taxi) > 0)
taxi_request[i.id, assigned_taxi := nearest_available_taxi]
NULL
}
]
taxi_request[, time_before := NULL][]
},
check = function(x, y) all.equal(x, y, check.attributes = FALSE),
min_time = 10,
min_iterations = 3L
) # mark
}
) # press
bm2
ggplot2::autoplot(bm2)
Parallelisation (an experiment)
As the OP has mentioned that the cities are grouped by region with disjoint cities and reportedly has tried to speed up computation using foreach() and %dopar% I have started to experiment with paralleisation as well.
The first experiment uses the multithreading capabilities of data.table, setDTthreads(2L). Unfortunately, the benchmark shows no speed-up for v6 (2 threads) vs v5 (1 thread) which might be due to hardware limitations. v4 was included to confirm that v5 is the faster version.

expression n_req n_city min median `itr/sec` mem_alloc `gc/sec` n_itr n_gc total_time result memory time
<bch:expr> <int> <int> <bch:> <bch:> <dbl> <bch:byt> <dbl> <int> <dbl> <bch:tm> <list> <list> <lis>
1 v4 20000 200 48.5s 48.5s 0.0206 3.44GB 0.639 1 31 48.5s <data… <Rpro… <ben…
2 v5 20000 200 31.3s 31.3s 0.0320 2.65GB 0.768 1 24 31.3s <data… <Rpro… <ben…
3 v6 20000 200 32.4s 32.4s 0.0309 2.65GB 0.710 1 23 32.4s <data… <Rpro… <ben…
4 v4 10000 200 23.1s 23.1s 0.0433 1.73GB 0.866 1 20 23.1s <data… <Rpro… <ben…
5 v5 10000 200 14.3s 14.3s 0.0698 1.32GB 1.05 1 15 14.3s <data… <Rpro… <ben…
6 v6 10000 200 14.3s 14.3s 0.0700 1.32GB 0.980 1 14 14.3s <data… <Rpro… <ben…
7 v4 20000 100 42.9s 42.9s 0.0233 3.36GB 0.630 1 27 42.9s <data… <Rpro… <ben…
8 v5 20000 100 30s 30s 0.0334 2.56GB 0.534 1 16 30s <data… <Rpro… <ben…
9 v6 20000 100 28.9s 28.9s 0.0346 2.56GB 0.485 1 14 28.9s <data… <Rpro… <ben…
10 v4 10000 100 21s 21s 0.0476 1.69GB 0.571 1 12 21s <data… <Rpro… <ben…
11 v5 10000 100 14.1s 14.1s 0.0711 1.29GB 0.711 1 10 14.1s <data… <Rpro… <ben…
12 v6 10000 100 14.2s 14.2s 0.0706 1.29GB 0.706 1 10 14.2s <data… <Rpro… <ben…
Anyhow, v5 took about 30 seconds to process 20000 requests and less than 15 seconds to process 10000 requests. The number of cities (and the number of taxi) in the makeshift datasets seem to have little effect on run time.
For OP's production dataset with 900000 requests, processing may take about 30 minutes (assuming linear behaviour). So, the question if it is worthwhile to investigate parallelisation further.
Additional benchmarks
To verify my assumption that the v5 code is fast enough to handle OP's production dataset I have conducted additional benchmarks with 10k, 20k, 40k, and 80k requests.
expression n_req n_city min median `itr/sec` mem_alloc `gc/sec` n_itr n_gc total_time result memory time
<bch:expr> <dbl> <int> <bch:t> <bch:> <dbl> <bch:byt> <dbl> <int> <dbl> <bch:tm> <list> <list> <lis>
1 v5 80000 100 1.98m 1.98m 0.00841 10.13GB 0.471 1 56 1.98m <data… <Rpro… <ben…
2 v5 40000 100 1.08m 1.08m 0.0154 5.12GB 0.200 1 13 1.08m <data… <Rpro… <ben…
3 v5 20000 100 30.06s 30.06s 0.0333 2.56GB 0.266 1 8 30.06s <data… <Rpro… <ben…
4 v5 10000 100 15.43s 15.43s 0.0648 1.29GB 0.324 1 5 15.43s <data… <Rpro… <ben…
Plotting the run times vs number of requests on double logarithmic (log-log) scale
library(ggplot2)
ggplot(bm4) +
aes(n_req, median, label = as.character(median)) +
scale_x_log10(breaks = bm4$n_req) + scale_y_log10() +
geom_point() +
geom_smooth(method = "lm", se = TRUE) +
ggrepel::geom_text_repel()
indicates that run times scales linearily with problem size and that run times of 25 to 30 minutes for 900k requests might be achievable (on the system used for benchmarking; your mileage may vary):

However, memory allocation might be a greater challenge than runtimes
ggplot(bm4) +
aes(n_req, mem_alloc, label = as.character(mem_alloc)) +
scale_x_log10(breaks = bm4$n_req) + scale_y_log10() +
geom_point() +
geom_smooth(method = "lm", se = TRUE) +
ggrepel::geom_text_repel()

Note the log-log scale. Again, memory allocation varies linearily with problem size. However, for the 80k case about 10 GBytes of memory were allocated. Extrapolating this to 900k, more than 110 Gbytes of memory would be required.
The conclusion is that splitting up the problem in smaller chunks by regions might be necessary to comply with the available memory.